Repository: python-attrs/attrs
Pull request #580 (2019-09-25 14:17:20)

Add attr.__version_info__
This allows users to check for features and avoid deprecation warnings without
breaking backward compatibility.

This is especially important with the cmp → order/eq split.

---

@euresti do I need to add it to `__init__.pyi` if it's an attrs class? I tried to just import it into it, but that started throwing errors about stuff in `_make.py`…

euresti: I think you should add it to the __init__.pyi and the other missing ones like this:

```
__version__: str
__title__: str
__description__: str
__url__: str
__uri__: str
__author__: str
__email__: str
__license__: str
__copyright__: str
```

hynek: Technically, mypy knows the types of VersionInfo…but if I import it, I get the errors…is there a way around here? Or do I have to either fix mypy for attrs or duplicate the information?

```
src/attr/_compat.py:18: error: Cannot assign multiple types to name "ordered_dict" without an explicit "Type[...]" annotation
src/attr/_make.py:1538: error: All conditional function variants must have identical signatures
src/attr/_make.py:1907: error: Cannot assign to a type
src/attr/_make.py:1909: error: "Attribute" has no attribute "hash"
src/attr/_make.py:2039: error: Cannot assign to a type
```

are the new ones.
euresti: Oh.  You shouldn't run mypy on attrs source code.  At least we haven't made it pass type checking.  The only file that passes type checking is `typing_example.py`
euresti: Although:
> src/attr/_make.py:1538: error: All conditional function variants must have identical signatures
is complaining that 
```
            def fmt_setter(attr_name, value_var):
            def fmt_setter(attr_name, value_var):
        # Not frozen.
        def fmt_setter(attr_name, value):
```

hynek: > Oh. You shouldn't run mypy on attrs source code. At least we haven't made it pass type checking. The only file that passes type checking is typing_example.py

I’m not! 🙈 I’m just adding a `from ._version import VersionInfo` And it seem to follow the imports into _make?
euresti: Oh I see.  You added `_version.py`  you'll probably have to add `_version.pyi`
But I can't repro your error messages.  What command are you running to trigger them?



Patch:
diff --git a/changelog.d/580.change.rst b/changelog.d/580.change.rst
new file mode 100644
index 0000000..74a9934
--- /dev/null
+++ b/changelog.d/580.change.rst
@@ -0,0 +1,2 @@
+Added ``attr.__version_info__`` that can be used to reliably check the version of ``attrs`` and write forward- and backward-compatible code.
+Please check out the `section on deprecated APIs <http://www.attrs.org/en/stable/api.html#deprecated-apis>`_ on how to use it.
diff --git a/docs/api.rst b/docs/api.rst
index f68aaa0..7e965b9 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -506,7 +506,31 @@ Converters
 Deprecated APIs
 ---------------
 
+.. _version-info:
+
+To help you write backward compatible code that doesn't throw warnings on modern releases, the ``attr`` module has an ``__version_info__`` attribute as of version 19.2.0.
+It behaves similarly to `sys.version_info` and is an instance of `VersionInfo`:
+
+.. autoclass:: VersionInfo
+
+   With its help you can write code like this:
+
+   >>> if getattr(attr, "__version_info__", (0,)) >= (19, 2):
+   ...     cmp_off = {"eq": False}
+   ... else:
+   ...     cmp_off = {"cmp": False}
+   >>> cmp_off == {"eq":  False}
+   True
+   >>> @attr.s(**cmp_off)
+   ... class C(object):
+   ...     pass
+
+
+----
+
 The serious business aliases used to be called ``attr.attributes`` and ``attr.attr``.
 There are no plans to remove them but they shouldn't be used in new code.
 
+The ``cmp`` argument to both `attr.s` and `attr.ib` has been deprecated in 19.2 and shouldn't be used.
+
 .. autofunction:: assoc
diff --git a/src/attr/__init__.py b/src/attr/__init__.py
index cf322d0..c28f056 100644
--- a/src/attr/__init__.py
+++ b/src/attr/__init__.py
@@ -16,9 +16,11 @@ from ._make import (
     make_class,
     validate,
 )
+from ._version import VersionInfo
 
 
 __version__ = "19.2.0.dev0"
+__version_info__ = VersionInfo._from_version_string(__version__)
 
 __title__ = "attrs"
 __description__ = "Classes Without Boilerplate"
@@ -37,6 +39,7 @@ s = attributes = attrs
 ib = attr = attrib
 dataclass = partial(attrs, auto_attribs=True)  # happy Easter ;)
 
+
 __all__ = [
     "Attribute",
     "Factory",
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index 03bcf89..d19a055 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -20,6 +20,19 @@ from . import filters as filters
 from . import converters as converters
 from . import validators as validators
 
+from ._version import VersionInfo
+
+__version__: str
+__version_info__: VersionInfo
+__title__: str
+__description__: str
+__url__: str
+__uri__: str
+__author__: str
+__email__: str
+__license__: str
+__copyright__: str
+
 _T = TypeVar("_T")
 _C = TypeVar("_C", bound=type)
 
diff --git a/src/attr/_version.py b/src/attr/_version.py
new file mode 100644
index 0000000..014e78a
--- /dev/null
+++ b/src/attr/_version.py
@@ -0,0 +1,85 @@
+from __future__ import absolute_import, division, print_function
+
+from functools import total_ordering
+
+from ._funcs import astuple
+from ._make import attrib, attrs
+
+
+@total_ordering
+@attrs(eq=False, order=False, slots=True, frozen=True)
+class VersionInfo(object):
+    """
+    A version object that can be compared to tuple of length 1--4:
+
+    >>> attr.VersionInfo(19, 1, 0, "final")  <= (19, 2)
+    True
+    >>> attr.VersionInfo(19, 1, 0, "final") < (19, 1, 1)
+    True
+    >>> vi = attr.VersionInfo(19, 2, 0, "final")
+    >>> vi < (19, 1, 1)
+    False
+    >>> vi < (19,)
+    False
+    >>> vi == (19, 2,)
+    True
+    >>> vi == (19, 2, 1)
+    False
+
+    .. versionadded:: 19.2
+    """
+
+    year = attrib(type=int)
+    minor = attrib(type=int)
+    micro = attrib(type=int)
+    releaselevel = attrib(type=str)
+
+    @classmethod
+    def _from_version_string(cls, s):
+        """
+        Parse *s* and return a _VersionInfo.
+        """
+        v = s.split(".")
+        if len(v) == 3:
+            v.append("final")
+
+        return cls(
+            year=int(v[0]), minor=int(v[1]), micro=int(v[2]), releaselevel=v[3]
+        )
+
+    def _ensure_tuple(self, other):
+        """
+        Ensure *other* is a tuple of a valid length.
+
+        Returns a possibly transformed *other* and ourselves as a tuple of
+        the same length as *other*.
+        """
+
+        if self.__class__ is other.__class__:
+            other = astuple(other)
+
+        if not isinstance(other, tuple):
+            raise NotImplementedError
+
+        if not (1 <= len(other) <= 4):
+            raise NotImplementedError
+
+        return astuple(self)[: len(other)], other
+
+    def __eq__(self, other):
+        try:
+            us, them = self._ensure_tuple(other)
+        except NotImplementedError:
+            return NotImplemented
+
+        return us == them
+
+    def __lt__(self, other):
+        try:
+            us, them = self._ensure_tuple(other)
+        except NotImplementedError:
+            return NotImplemented
+
+        # Since alphabetically "dev0" < "final" < "post1" < "post2", we don't
+        # have to do anything special with releaselevel for now.
+        return us < them
diff --git a/src/attr/_version.pyi b/src/attr/_version.pyi
new file mode 100644
index 0000000..45ced08
--- /dev/null
+++ b/src/attr/_version.pyi
@@ -0,0 +1,9 @@
+class VersionInfo:
+    @property
+    def year(self) -> int: ...
+    @property
+    def minor(self) -> int: ...
+    @property
+    def micro(self) -> int: ...
+    @property
+    def releaselevel(self) -> str: ...
diff --git a/tox.ini b/tox.ini
index 77ebe24..96a4728 100644
--- a/tox.ini
+++ b/tox.ini
@@ -93,5 +93,5 @@ commands = towncrier --draft
 basepython = python3.7
 deps = mypy
 commands =
-    mypy src/attr/__init__.pyi src/attr/converters.pyi src/attr/exceptions.pyi src/attr/filters.pyi src/attr/validators.pyi
+    mypy src/attr/__init__.pyi src/attr/_version.pyi src/attr/converters.pyi src/attr/exceptions.pyi src/attr/filters.pyi src/attr/validators.pyi
     mypy tests/typing_example.py


Test patch:
diff --git a/tests/test_version.py b/tests/test_version.py
new file mode 100644
index 0000000..6b33770
--- /dev/null
+++ b/tests/test_version.py
@@ -0,0 +1,51 @@
+from __future__ import absolute_import, division, print_function
+
+import pytest
+
+from attr import VersionInfo
+from attr._compat import PY2
+
+
+@pytest.fixture(name="vi")
+def fixture_vi():
+    return VersionInfo(19, 2, 0, "final")
+
+
+class TestVersionInfo:
+    def test_from_string_no_releaselevel(self, vi):
+        """
+        If there is no suffix, the releaselevel becomes "final" by default.
+        """
+        assert vi == VersionInfo._from_version_string("19.2.0")
+
+    @pytest.mark.skipif(
+        PY2, reason="Python 2 is too YOLO to care about comparability."
+    )
+    @pytest.mark.parametrize("other", [(), (19, 2, 0, "final", "garbage")])
+    def test_wrong_len(self, vi, other):
+        """
+        Comparing with a tuple that has the wrong length raises an error.
+        """
+        assert vi != other
+
+        with pytest.raises(TypeError):
+            vi < other
+
+    @pytest.mark.parametrize("other", [[19, 2, 0, "final"]])
+    def test_wrong_type(self, vi, other):
+        """
+        Only compare to other VersionInfos or tuples.
+        """
+        assert vi != other
+
+    def test_order(self, vi):
+        """
+        Ordering works as expected.
+        """
+        assert vi < (20,)
+        assert vi < (19, 2, 1)
+        assert vi > (0,)
+        assert vi <= (19, 2)
+        assert vi >= (19, 2)
+        assert vi > (19, 2, 0, "dev0")
+        assert vi < (19, 2, 0, "post1")