Repository: yt-project/unyt
Pull request #364 (2023-02-06 20:48:07)

REL: prep release 2.9.4
Backports:
- #278
- #344 
- #358
- #362

TODO:
- [x] pass tests
- [x] update HISTORY


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0119b55..26704ec 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,27 +38,7 @@ jobs:
           python-version: ${{ matrix.python-version }}
       - name: Install Tox and any other packages
         run: |
-          python -m pip install --upgrade pip setuptools wheel
-          python -m pip install tox tox-pyenv coverage
-      - name: Build Docs
-        if: matrix.python-version == '3.8'
-        run: tox -e py38-docs -vvv
-      - name: Run Minimal Dependency Build
-        if: matrix.python-version == '3.8'
-        run: tox -e py38-dependencies
-      - name: Run Minimal Dependency Version Build
-        if: matrix.python-version == '3.8'
-        run: tox -e py38-versions
-      - name: Run Main Build
-        # Run tox using the version of Python in `PATH`
-        run: tox -e py -vvv
-      - name: Run In-place Tests
-        if: matrix.python-version == '3.8'
-        run: tox -e py38-unyt-module-test-function
-      - name: Report coverage with Codecov
-        if: github.event_name == 'push' && matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest'
-        uses: codecov/codecov-action@v1
-        with:
-          # token: ${{ secrets.CODECOV_TOKEN }}
-          file: ./coverage.xml
-          flags: unittests
+          python -m pip install --upgrade pip
+          python -m pip install tox tox-gh-actions
+      - name: Test
+        run: tox -vvv
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b92c7ae..e716e4d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,7 +24,7 @@ repos:
     hooks:
     - id: black
 -   repo: https://github.com/PyCQA/isort
-    rev: 5.10.1
+    rev: 5.12.0
     hooks:
     - id: isort
       name: isort
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 2b92c0a..508fbcb 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -98,7 +98,7 @@ managing your python evironment using your operating system's package manager or
     $ pyenv install -s 3.8.13
     $ pyenv install -s 3.9.12
     $ pyenv install -s 3.10.4
-    $ pip install tox tox-pyenv
+    $ pip install tox
 
 4. Install your local copy into a virtualenv or conda environment. You can also
    use one of the python interpreters we installed using ``pyenv``::
diff --git a/HISTORY.rst b/HISTORY.rst
index b0a9117..c574a9b 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -2,6 +2,30 @@
 History
 =======
 
+2.9.4 (2023-02-06)
+------------------
+
+* Make ``unyt_quantity.from_string`` parse ints.
+  See `PR #278 <https://github.com/yt-project/unyt/pull/278>`_.
+  Thank you to Nahan Goldbaum (@ngoldbaum on GitHub) for the contribution.
+* TST: migrate from tox-pyenv to tox-gh-actions #344
+  See `PR #344 <https://github.com/yt-project/unyt/pull/344>`_.
+  Thank you to Clément Robert (@neutrinoceros on GitHub) for the contribution.
+* Correctly test string comparison depending on numpy version #358
+  See `PR #358 <https://github.com/yt-project/unyt/pull/358>`_.
+  Thank you to Clément Robert (@neutrinoceros on GitHub) for the contribution.
+* Multiple fixes for ``unyt_quantity.from_string``
+  - fix a bug where ``unyt_quantity.from_string`` would drop part of the unit expression
+  - fix a bug where ``unyt_quantity.from_string`` would choke on unit expressions starting with ``'*'`` or ``'/'``
+  - fix a bug where ``unyt_quantity.from_string`` would choke on space-separated unit expressions
+  - fix roundtrip for ``unyt_quantity.from_string`` and ``unyt_quantity.to_string`` methods
+  - simplify unit regexp (``'**/2'`` isn't a valid exponent)
+  - fix a bug where malformed string input would be incorrectly parsed by ``unyt_quantity.from_string``
+  See `PR #362 <https://github.com/yt-project/unyt/pull/362>`_.
+  Thank you to Clément Robert (@neutrinoceros on GitHub) for the contribution,
+  and to Chris Byrohl (@cbyrohl on GitHub) for the report.
+
+
 2.9.3 (2022-12-07)
 ------------------
 
@@ -9,7 +33,7 @@ History
   ``unyt_array`` objects to non-numeric objects (e.g. strings) would cause a
   crash. See `PR #333 <https://github.com/yt-project/unyt/pull/333>`_. Thank you
   to Clément Robert (@neutrinoceros on GitHub) and Nathan Goldbaum (@ngoldbaum
-  on GitHub) the contribution.
+  on GitHub) for the contribution.
 
 2.9.2 (2022-07-20)
 ------------------
diff --git a/README.rst b/README.rst
index d17ee99..9b92255 100644
--- a/README.rst
+++ b/README.rst
@@ -10,10 +10,10 @@ unyt
         :target: https://anaconda.org/conda-forge/unyt
         :alt: conda-forge
 
-.. image:: https://github.com/yt-project/unyt/actions/workflows/ci.yml/badge.svg?branch=master
+.. image:: https://github.com/yt-project/unyt/actions/workflows/ci.yml/badge.svg?branch=main
         :target: https://github.com/yt-project/unyt/actions/workflows/ci.yml
 
-.. image:: https://github.com/yt-project/unyt/actions/workflows/bleeding-edge.yaml/badge.svg?branch=master
+.. image:: https://github.com/yt-project/unyt/actions/workflows/bleeding-edge.yaml/badge.svg?branch=main
         :target: https://github.com/yt-project/unyt/actions/workflows/bleeding-edge.yaml
 
 .. image:: https://readthedocs.org/projects/unyt/badge/?version=latest
diff --git a/docs/usage.rst b/docs/usage.rst
index f18d404..2e089d4 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -834,7 +834,7 @@ Quantities can also be parsed from strings with the :func:`unyt_quantity.from_st
 
   >>> from unyt import unyt_quantity
   >>> unyt_quantity.from_string("1 cm")
-  unyt_quantity(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")
diff --git a/tox.ini b/tox.ini
index 022fd12..8160c4b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,12 @@
 [tox]
 envlist = py38-docs,begin,py38-dependencies,py38-versions,py{38,39,310},py38-unyt-module-test-function,end
 
+[gh-actions]
+python =
+    3.8: py38, py38-docs, py38-dependencies, py38-versions, py38-unyt-module-test-function
+    3.9: py39
+    3.10: py310
+
 [testenv]
 setenv =
     PYTHONPATH = {toxinidir}
@@ -60,7 +66,7 @@ commands =
     coverage report --omit='.tox/*'
 
 [testenv:py38-docs]
-whitelist_externals = make
+allowlist_externals = make
 changedir = docs
 depends =
 deps =
diff --git a/unyt/array.py b/unyt/array.py
index 6696052..e51066f 100644
--- a/unyt/array.py
+++ b/unyt/array.py
@@ -150,11 +150,13 @@ __doctest_requires__ = {
 
 # 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
+_NUMB_PATTERN = (
+    r"[+/-]?(?:((?:\d\.?\d*[Ee][+\-]?\d+)|(?:\d+\.\d*|\d*\.\d+))|\d+|inf\s|nan\s)"
+)
 # *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)
+_UNIT_PATTERN = r"((\s*[*/]\s*)?[α-ωΑ-Ωa-zA-Z]+(\*\*([+-]?\d+|\([+-]?\d+\)))?)+"
+_QUAN_PATTERN = rf"{_NUMB_PATTERN}\s*{_UNIT_PATTERN}"
 _NUMB_REGEXP = re.compile(_NUMB_PATTERN)
 _UNIT_REGEXP = re.compile(_UNIT_PATTERN)
 _QUAN_REGEXP = re.compile(_QUAN_PATTERN)
@@ -1374,7 +1376,7 @@ class unyt_array(np.ndarray):
         --------
         >>> from unyt import unyt_quantity
         >>> unyt_quantity.from_string("1cm")
-        unyt_quantity(1., 'cm')
+        unyt_quantity(1, 'cm')
         >>> unyt_quantity.from_string("+1e3 Mearth")
         unyt_quantity(1000., 'Mearth')
         >>> unyt_quantity.from_string("-10. kg")
@@ -1382,22 +1384,31 @@ class unyt_array(np.ndarray):
         >>> unyt_quantity.from_string(".66\tum")
         unyt_quantity(0.66, 'μm')
         >>> unyt_quantity.from_string("42")
-        unyt_quantity(42., '(dimensionless)')
+        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)
+            num = re.match(_NUMB_REGEXP, v).group()
+            unit = Unit()
+        elif re.fullmatch(_UNIT_REGEXP, v):
+            num = 1
+            unit = Unit(re.match(_UNIT_REGEXP, v).group())
+        elif not re.fullmatch(_QUAN_REGEXP, v):
+            raise ValueError(f"Received invalid quantity expression '{s}'.")
+        else:
+            res = re.search(_NUMB_REGEXP, v)
+            num = res.group()
+            res = re.search(_UNIT_REGEXP, v[res.span()[1] :])
+            unit = res.group().strip()
+            if unit.startswith(("/", "*")):
+                unit = f"1{unit}"
+        try:
+            num = int(num)
+        except ValueError:
+            num = float(num)
+        return num * Unit(unit, registry=unit_registry)
 
     def to_string(self):
         # this is implemented purely for symmetry's sake


Test patch:
diff --git a/unyt/tests/test_unyt_array.py b/unyt/tests/test_unyt_array.py
index e2a6dcc..dedcc64 100644
--- a/unyt/tests/test_unyt_array.py
+++ b/unyt/tests/test_unyt_array.py
@@ -24,6 +24,7 @@ import re
 import shutil
 import tempfile
 import warnings
+from importlib.metadata import version
 from pathlib import Path
 
 import numpy as np
@@ -70,6 +71,8 @@ from unyt.testing import _process_warning, assert_allclose_units
 from unyt.unit_registry import UnitRegistry
 from unyt.unit_symbols import cm, degree, g, m
 
+NUMPY_VERSION = Version(version("numpy"))
+
 
 def operate_and_compare(a, b, op, answer):
     # Test generator for unyt_arrays tests
@@ -1492,11 +1495,16 @@ def test_string_operations_raise_errors():
         a * "hello"
     with pytest.raises(IterableUnitCoercionError):
         a ** "hello"
-    if Version(np.__version__) < Version("1.24"):
-        with pytest.warns(FutureWarning):
-            assert a != "hello"
+
+
+def test_string_ne():
+    a = unyt_array([1, 2, 3], "g")
+    if NUMPY_VERSION >= Version("1.25.0.dev0"):
+        ctx = pytest.raises(ValueError)
     else:
-        assert (a != "hello").all()
+        ctx = pytest.warns(FutureWarning)
+    with ctx:
+        assert a != "hello"
 
 
 def test_string_operations_raise_errors_quantity():
@@ -2583,42 +2591,58 @@ def test_string_formatting():
 
 
 @pytest.mark.parametrize(
-    "s, expected",
+    "s, expected, normalized",
     [
-        ("+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")),
+        ("+1cm", 1.0 * Unit("cm"), "1 cm"),
+        ("1cm", 1.0 * Unit("cm"), "1 cm"),
+        ("1.cm", 1.0 * Unit("cm"), "1.0 cm"),
+        ("1.0 cm", 1.0 * Unit("cm"), "1.0 cm"),
+        ("1.0\tcm", 1.0 * Unit("cm"), "1.0 cm"),
+        ("1.0\t cm", 1.0 * Unit("cm"), "1.0 cm"),
+        ("1.0  cm", 1.0 * Unit("cm"), "1.0 cm"),
+        ("1.0\t\tcm", 1.0 * Unit("cm"), "1.0 cm"),
+        ("10e-1cm", 1.0 * Unit("cm"), "1.0 cm"),
+        ("10E-1cm", 1.0 * Unit("cm"), "1.0 cm"),
+        ("+1cm", 1.0 * Unit("cm"), "1 cm"),
+        ("1um", 1.0 * Unit("μm"), "1 μm"),
+        ("1μm", 1.0 * Unit("μm"), "1 μm"),
+        ("-5 Msun", -5.0 * Unit("Msun"), "-5 Msun"),
+        ("1e3km", 1e3 * Unit("km"), "1000.0 km"),
+        ("-1e3    km", -1e3 * Unit("km"), "-1000.0 km"),
+        ("1.0 g/cm**3", 1.0 * Unit("g/cm**3"), "1.0 g/cm**3"),
+        ("1 g*cm**-3", 1.0 * Unit("g/cm**3"), "1 g/cm**3"),
+        ("1.0 g*cm", 1.0 * Unit("g*cm"), "1.0 cm*g"),
+        ("nan g", float("nan") * Unit("g"), "nan g"),
+        ("-nan g", float("nan") * Unit("g"), "nan g"),
+        ("inf g", float("inf") * Unit("g"), "inf g"),
+        ("+inf g", float("inf") * Unit("g"), "inf g"),
+        ("-inf g", -float("inf") * Unit("g"), "-inf g"),
+        ("1", 1.0 * Unit(), "1 dimensionless"),
+        ("g", 1.0 * Unit("g"), "1 g"),
+        # from https://github.com/yt-project/unyt/issues/361
+        ("1 g**2/cm**2", 1.0 * Unit("g") ** 2 / Unit("cm") ** 2, "1 g**2/cm**2"),
+        ("g**2/cm**2", 1.0 * Unit("g") ** 2 / Unit("cm") ** 2, "1 g**2/cm**2"),
+        ("1*cm**2", 1.0 * Unit("cm") ** 2, "1 cm**2"),
+        ("1/cm**2", 1.0 / Unit("cm") ** 2, "1 cm**(-2)"),
+        ("1 / cm**2", 1.0 / Unit("cm") ** 2, "1 cm**(-2)"),
+        (
+            "1e-3 g**2 / cm**2",
+            1e-3 * Unit("g") ** 2 / Unit("cm") ** 2,
+            "0.001 g**2/cm**2",
+        ),
     ],
 )
-def test_valid_quantity_from_string(s, expected):
+def test_valid_quantity_from_string(s, expected, normalized):
     actual = unyt_quantity.from_string(s)
-    if "nan" in s:
-        assert actual != expected
-    else:
+    assert actual.to_string() == normalized
+    roundtrip = unyt_quantity.from_string(actual.to_string())
+
+    if "nan" not in s:
         assert actual == expected
+        assert roundtrip == expected
+
+    assert actual.to_string() == normalized
+    assert roundtrip.to_string() == normalized
 
 
 @pytest.mark.parametrize(
@@ -2629,6 +2653,9 @@ def test_valid_quantity_from_string(s, expected):
         "cm10",
         "cm 10.",
         ".cm",
+        "1cm**(-1",
+        "1cm**/2",
+        "1cm**3 hello",
     ],
 )
 def test_invalid_expression_quantity_from_string(s):