Repository: yt-project/unyt
Pull request #191 (2021-07-07 09:48:22)

FEAT: implement unyt.unyt_quantity.from_string
This adds a from_string method to the unyt_quantity class. I originally wrote it in a separate project where I need to parse quantities from configuration (text) files, then realized it would be useful to have it as part of the library.

I consider this a draft for now. The implementation works as intended in every case I could think of (valid as well as invalid ones), but I would like to add doc strings (w/ doctests) to the actual function.

JBorrow: This is extremely useful as an addition, thanks!
neutrinoceros: Linter tests are failing, I assume that #189 would fix this.
matthewfeickert: > Linter tests are failing, I assume that #189 would fix this.

Yeah, I just checked this branch out in my fork and rebased it on top of PR #190 and it is failing the flake8 and black linter tests.

```
unyt/tests/test_linters.py FF  
```

So once PR #190 is merged a rebase here with application of `black` and some small lke8 stuff should have everything green and shiny. :)
neutrinoceros: excellent, let's focus on #190 then !
neutrinoceros: rebased and ready for review !
jzuhone: @matthewturk care to look at this?
matthewturk: @jzuhone @neutrinoceros this looks good to me, but I am not sure I am authoritative on the subject!  I think it's a nice addition.

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index c670a4d..3a6f8bd 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -21,6 +21,7 @@ Contributors
 * Kacper Kowalik <xarthisius.kk@gmail.com>
 * Nathan Musoke <nathan.musoke@gmail.com>
 * Andrew Myers <atmyers2@gmail.com>
+* Clément Robert
 * Simon Schopferer <simon.schopferer@dlr.de>
 * Sam Skillman <samskillman@gmail.com>
 * Britton Smith <brittonsmith@gmail.com>
diff --git a/docs/usage.rst b/docs/usage.rst
index 4f01600..f3fa25c 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -827,6 +827,25 @@ method:
   <Quantity([1 2 3], 'centimeter')>
 
 
+Reading quantities from text
+----------------------------
+
+Quantities can also be parsed from strings with the :func:`unyt_quantity.from_string <unyt.unyt_quantity.from_string>` function:
+
+  >>> from unyt import unyt_quantity
+  >>> unyt_quantity.from_string("1 cm")
+  unyt_quantity(1., 'cm')
+  >>> unyt_quantity.from_string("1e3 Msun")
+  unyt_quantity(1000., 'Msun')
+  >>> unyt_quantity.from_string("1e-3 g/cm**3")
+  unyt_quantity(0.001, 'g/cm**3')
+
+This method is helpful to read data from text files, for instance configuration
+files. It is intended to be as flexible as possible on the string format, though
+it requires that the numerical value and the unit name be separated with some
+kind of whitespace.
+
+
 User-Defined Units
 ++++++++++++++++++
 
diff --git a/unyt/array.py b/unyt/array.py
index 968b618..144a2c7 100644
--- a/unyt/array.py
+++ b/unyt/array.py
@@ -17,6 +17,7 @@ import copy
 
 from functools import lru_cache
 from numbers import Number as numeric_type
+import re
 import numpy as np
 from numpy import (
     add,
@@ -145,6 +146,17 @@ __doctest_requires__ = {
     ("unyt_array.from_astropy", "unyt_array.to_astropy"): ["astropy"],
 }
 
+# This is partially adapted from the following SO thread
+# https://stackoverflow.com/questions/41668588/regex-to-match-scientific-notation
+_NUMB_PATTERN = r"^[+/-]?((?:0|[1-9]\d*)(?:\.\d*)?(?:[eE][+\-]?\d+)|\d*\.?\d+|\d+\.?\d*|nan\s|inf\s)"  # noqa: E501
+# *all* greek letters are considered valid unit string elements.
+# This may be an overshoot. We rely on unyt.Unit to do the actual validation
+_UNIT_PATTERN = r"([α-ωΑ-Ωa-zA-Z]+(\*\*([+/-]?[0-9]+)|[*/])?)+"
+_QUAN_PATTERN = r"{}\s*{}".format(_NUMB_PATTERN, _UNIT_PATTERN)
+_NUMB_REGEXP = re.compile(_NUMB_PATTERN)
+_UNIT_REGEXP = re.compile(_UNIT_PATTERN)
+_QUAN_REGEXP = re.compile(_QUAN_PATTERN)
+
 
 def _iterable(obj):
     try:
@@ -1204,8 +1216,8 @@ class unyt_array(np.ndarray):
         ----------
         arr : AstroPy Quantity
             The Quantity to convert from.
-        unit_registry : yt UnitRegistry, optional
-            A yt unit registry to use in the conversion. If one is not
+        unit_registry : unyt.UnitRegistry, optional
+            A unyt unit registry to use in the conversion. If one is not
             supplied, the default one will be used.
 
         Example
@@ -1262,8 +1274,8 @@ class unyt_array(np.ndarray):
         ----------
         arr : Pint Quantity
             The Quantity to convert from.
-        unit_registry : yt UnitRegistry, optional
-            A yt unit registry to use in the conversion. If one is not
+        unit_registry : unyt.UnitRegistry, optional
+            A unyt unit registry to use in the conversion. If one is not
             supplied, the default one will be used.
 
         Examples
@@ -1300,7 +1312,7 @@ class unyt_array(np.ndarray):
         unit_registry : Pint UnitRegistry, optional
             The Pint UnitRegistry to use in the conversion. If one is not
             supplied, the default one will be used. NOTE: This is not
-            the same as a yt UnitRegistry object.
+            the same as a unyt.UnitRegistry object.
 
         Examples
         --------
@@ -1324,6 +1336,52 @@ class unyt_array(np.ndarray):
         units = "*".join(units)
         return unit_registry.Quantity(self.value, units)
 
+    @staticmethod
+    def from_string(s, unit_registry=None):
+        """
+        Parse a string to a unyt_quantity object.
+
+        Parameters
+        ----------
+        s: str
+           A string representing a single quantity.
+        unit_registry: unyt.UnitRegistry, optional
+            A unyt unit registry to use in the conversion. If one is not
+            supplied, the default one will be used.
+
+        Examples
+        --------
+        >>> from unyt import unyt_quantity
+        >>> unyt_quantity.from_string("1cm")
+        unyt_quantity(1., 'cm')
+        >>> unyt_quantity.from_string("+1e3 Mearth")
+        unyt_quantity(1000., 'Mearth')
+        >>> unyt_quantity.from_string("-10. kg")
+        unyt_quantity(-10., 'kg')
+        >>> unyt_quantity.from_string(".66\tum")
+        unyt_quantity(0.66, 'μm')
+        >>> unyt_quantity.from_string("42")
+        unyt_quantity(42., '(dimensionless)')
+        >>> unyt_quantity.from_string("1.0 g/cm**3")
+        unyt_quantity(1., 'g/cm**3')
+        """
+        v = s.strip()
+        if re.fullmatch(_NUMB_REGEXP, v):
+            return float(re.match(_NUMB_REGEXP, v).group()) * Unit()
+        if re.fullmatch(_UNIT_REGEXP, v):
+            return 1 * Unit(re.match(_UNIT_REGEXP, v).group())
+        if not re.match(_QUAN_REGEXP, v):
+            raise ValueError("Received invalid quantity expression '{}'.".format(s))
+        res = re.search(_NUMB_REGEXP, v)
+        num = res.group()
+        res = re.search(_UNIT_REGEXP, v[res.span()[1] :])
+        unit = res.group()
+        return float(num) * Unit(unit, registry=unit_registry)
+
+    def to_string(self):
+        # this is implemented purely for symmetry's sake
+        return str(self)
+
     #
     # End unit conversion methods
     #


Test patch:
diff --git a/unyt/tests/test_unyt_array.py b/unyt/tests/test_unyt_array.py
index 847e1be..7b4ec7a 100644
--- a/unyt/tests/test_unyt_array.py
+++ b/unyt/tests/test_unyt_array.py
@@ -2464,3 +2464,78 @@ def test_string_formatting():
     expected = "[1 2 3] Msun"
     assert "%s" % d == expected
     assert "{}".format(d) == expected
+
+
+@pytest.mark.parametrize(
+    "s, expected",
+    [
+        ("+1cm", 1.0 * Unit("cm")),
+        ("1cm", 1.0 * Unit("cm")),
+        ("1.cm", 1.0 * Unit("cm")),
+        ("1.0 cm", 1.0 * Unit("cm")),
+        ("1.0\tcm", 1.0 * Unit("cm")),
+        ("1.0\t cm", 1.0 * Unit("cm")),
+        ("1.0  cm", 1.0 * Unit("cm")),
+        ("1.0\t\tcm", 1.0 * Unit("cm")),
+        ("10e-1cm", 1.0 * Unit("cm")),
+        ("10E-1cm", 1.0 * Unit("cm")),
+        ("+1cm", 1.0 * Unit("cm")),
+        ("1um", 1.0 * Unit("μm")),
+        ("1μm", 1.0 * Unit("μm")),
+        ("-5 Msun", -5.0 * Unit("Msun")),
+        ("1e3km", 1e3 * Unit("km")),
+        ("-1e3    km", -1e3 * Unit("km")),
+        ("1.0 g/cm**3", 1.0 * Unit("g/cm**3")),
+        ("1 g*cm**-3", 1.0 * Unit("g/cm**3")),
+        ("1.0 g*cm", 1.0 * Unit("g*cm")),
+        ("nan g", float("nan") * Unit("g")),
+        ("-nan g", float("nan") * Unit("g")),
+        ("inf g", float("inf") * Unit("g")),
+        ("+inf g", float("inf") * Unit("g")),
+        ("-inf g", -float("inf") * Unit("g")),
+        ("1", 1.0 * Unit()),
+        ("g", 1.0 * Unit("g")),
+    ],
+)
+def test_valid_quantity_from_string(s, expected):
+    actual = unyt_quantity.from_string(s)
+    if "nan" in s:
+        assert actual != expected
+    else:
+        assert actual == expected
+
+
+@pytest.mark.parametrize(
+    "s",
+    [
+        "++1cm",
+        "--1cm",
+        "cm10",
+        "cm 10.",
+        ".cm",
+    ],
+)
+def test_invalid_expression_quantity_from_string(s):
+    with pytest.raises(ValueError, match=r"^(Received invalid quantity expression )"):
+        unyt_quantity.from_string(s)
+
+
+@pytest.mark.parametrize(
+    "s",
+    [
+        "10 cmmmm",
+        "50. Km",
+        ".6   MSUN",
+        "infcm",  # space sep is required here
+    ],
+)
+def test_invalid_unit_quantity_from_string(s):
+    # using a lazy solution here
+    # this test would need to be refactored if we want to add other cases
+    # without a space separator between number and unit.
+    un_str = s.split()[-1]
+    with pytest.raises(
+        UnitParseError,
+        match="Could not find unit symbol '{}' in the provided symbols.".format(un_str),
+    ):
+        unyt_quantity.from_string(s)