Repository: yt-project/yt

Issue:
ENH: add a first class citizen version-tuple 
With the undergoing deprecation of distutils, the ecosystem is more and more relying on `packaging.version` to compare versions of dependencies at runtime.
However, using `packaging.version` internally makes for somewhat messy/busy code. As an example here's the minimal amount of code we need to write to check if e.g. matplotlib's version is greater than 3.5 at runtime
```python
from packaging.version import Version
import matplotlib

if Version(matplotlib.__version__) >= Version("3.5"):
    ...
```

But there is an alternative solution, and it has been popping out in various projects in our ecosystem recently:

- IPython 8.0.0 exposes a `version_info` attribute (taking inspiration from `sys.version_info` for Python itself), which is a simple `tuple[int, int, int]` that can be used for comparison.
- Matplotlib 3.5.0 exposes a `__version_info__` attribute for the same reason,
- numpy 1.23 will ship a `version_tuple` https://github.com/numpy/numpy/pull/20803

Long term, specifically, when we're done supporting matplotlib versions older than 3.5, we'll be able to simplify the example above as
```python
import matplotlib

if matplotlib.__version_info__ >= (3, 5):
    ...
```



So... the benefit of having such a "version tuple" exposed is not immediate as downstream need to require the first version that exposes it to be able to rely on it, however the cost is also very marginal so I think it is worth it. It'll actually lower the maintenance code downstream eventually.

(note: I know that downstream code written in a yt-version-sensitive fashion exists... because I wrote some myself.)

The reason I'm not proposing a patch straight away is that there is currently a naming discrepancy in the ecosystem (not 2 of the examples I gave above chose the same name for the attribute), but a uniformisation effort is being discussed (https://github.com/numpy/numpy/pull/20803#issuecomment-1024470449) so it seems worth waiting on this conversation to resolve first.
I anticipate this would be a reasonable task to add to the 4.1 milestone.



Discussion:

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -15,8 +15,6 @@
 
 install_ccompiler()
 
-VERSION = "4.1.dev0"
-
 if os.path.exists("MANIFEST"):
     os.remove("MANIFEST")
 
diff --git a/yt/__init__.py b/yt/__init__.py
--- a/yt/__init__.py
+++ b/yt/__init__.py
@@ -7,8 +7,7 @@
 * Contribute: https://github.com/yt-project/yt
 
 """
-__version__ = "4.1.dev0"
-
+from ._version import __version__, version_info  # isort: skip
 import yt.units as units
 import yt.utilities.physical_constants as physical_constants
 from yt.data_objects.api import (
diff --git a/yt/_version.py b/yt/_version.py
new file mode 100644
--- /dev/null
+++ b/yt/_version.py
@@ -0,0 +1,55 @@
+from typing import NamedTuple
+
+from packaging.version import Version
+
+__all__ = [
+    "__version__",
+    "version_info",
+]
+
+__version__ = "4.1.dev0"  # keep in sync with setup.cfg
+
+
+class VersionTuple(NamedTuple):
+    """
+    A minimal representation of the current version number
+    that can be used downstream to check the runtime version
+    simply by comparing with builtin tuples, as can be done with
+    the runtime Python version using sys.version_info
+
+    https://docs.python.org/3/library/sys.html#sys.version_info
+    """
+
+    major: int
+    minor: int
+    micro: int
+    releaselevel: str
+    serial: int
+
+
+def _parse_to_version_info(version_str: str) -> VersionTuple:
+    # adapted from matplotlib 3.5
+    """
+    Parse a version string to a namedtuple analogous to sys.version_info.
+    See:
+    https://packaging.pypa.io/en/latest/version.html#packaging.version.parse
+    https://docs.python.org/3/library/sys.html#sys.version_info
+    """
+    v = Version(version_str)
+    if v.pre is None and v.post is None and v.dev is None:
+        return VersionTuple(v.major, v.minor, v.micro, "final", 0)
+    elif v.dev is not None:
+        return VersionTuple(v.major, v.minor, v.micro, "alpha", v.dev)
+    elif v.pre is not None:
+        releaselevel = {"a": "alpha", "b": "beta", "rc": "candidate"}.get(
+            v.pre[0], "alpha"
+        )
+        return VersionTuple(v.major, v.minor, v.micro, releaselevel, v.pre[1])
+    elif v.post is not None:
+        # fallback for v.post: guess-next-dev scheme from setuptools_scm
+        return VersionTuple(v.major, v.minor, v.micro + 1, "alpha", v.post)
+    else:
+        return VersionTuple(v.major, v.minor, v.micro + 1, "alpha", 0)
+
+
+version_info = _parse_to_version_info(__version__)


Test patch:
diff --git a/tests/tests.yaml b/tests/tests.yaml
--- a/tests/tests.yaml
+++ b/tests/tests.yaml
@@ -201,6 +201,7 @@ other_tests:
      - "--ignore-files=test_outputs_pytest\\.py"
      - "--ignore-files=test_normal_plot_api\\.py"
      - "--ignore-file=test_file_sanitizer\\.py"
+     - "--ignore-files=test_version\\.py"
      - "--exclude-test=yt.frontends.gdf.tests.test_outputs.TestGDF"
      - "--exclude-test=yt.frontends.adaptahop.tests.test_outputs"
      - "--exclude-test=yt.frontends.stream.tests.test_stream_particles.test_stream_non_cartesian_particles"
diff --git a/yt/tests/test_version.py b/yt/tests/test_version.py
new file mode 100644
--- /dev/null
+++ b/yt/tests/test_version.py
@@ -0,0 +1,35 @@
+import pytest
+
+import yt
+from yt._version import VersionTuple, _parse_to_version_info
+
+
+@pytest.mark.parametrize(
+    "version_str, expected",
+    (
+        ("4.1.0", VersionTuple(4, 1, 0, "final", 0)),
+        ("6.2.5", VersionTuple(6, 2, 5, "final", 0)),
+        ("4.1.dev0", VersionTuple(4, 1, 0, "alpha", 0)),
+        ("4.1.0rc", VersionTuple(4, 1, 0, "candidate", 0)),
+        ("4.1.0rc1", VersionTuple(4, 1, 0, "candidate", 1)),
+        ("4.1.0rc2", VersionTuple(4, 1, 0, "candidate", 2)),
+    ),
+)
+def test_parse_version_info(version_str, expected):
+    actual = _parse_to_version_info(version_str)
+    assert actual == expected
+
+
+def test_version_tuple_comp():
+    # exercise comparison with builtin tuples
+    # comparison results do not matter for this test
+
+    yt.version_info > (4,)  # noqa: B015
+    yt.version_info > (4, 1)  # noqa: B015
+    yt.version_info > (4, 1, 0)  # noqa: B015
+    yt.version_info < (4, 1, 0)  # noqa: B015
+    yt.version_info <= (4, 1, 0)  # noqa: B015
+    yt.version_info >= (4, 1, 0)  # noqa: B015
+    yt.version_info == (4, 1, 0)  # noqa: B015
+
+    assert isinstance(yt.version_info, tuple)