Skip to content

Commit

Permalink
Add attr.__version_info__ (#580)
Browse files Browse the repository at this point in the history
* Add attr.__version_info__

This allows users to check for features and avoid deprecation warnings without
breaking backward compatibility.

* Add newsfragment

* Stay ASCII

* Typo

* Add stubs for _version.py

* Address David's feedback

* Handle PY2 better in comparability test

* drop the ing
  • Loading branch information
hynek committed Oct 1, 2019
1 parent 754fae0 commit 955d622
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 1 deletion.
2 changes: 2 additions & 0 deletions changelog.d/580.change.rst
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions src/attr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
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"
Expand All @@ -37,6 +39,7 @@
ib = attr = attrib
dataclass = partial(attrs, auto_attribs=True) # happy Easter ;)


__all__ = [
"Attribute",
"Factory",
Expand Down
13 changes: 13 additions & 0 deletions src/attr/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
85 changes: 85 additions & 0 deletions src/attr/_version.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions src/attr/_version.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
51 changes: 51 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 955d622

Please sign in to comment.