diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d97a8869a..07220075e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,9 +35,16 @@ jobs: - name: "Run tox targets for ${{ matrix.python-version }}" run: "python -m tox" + # We always use a modern Python version for combining coverage to prevent + # parsing errors in older versions for modern code. + - uses: "actions/setup-python@v2" + with: + python-version: "3.8" + - name: "Combine coverage" run: | set -xe + python -m pip install coverage[toml] python -m coverage combine python -m coverage xml if: "contains(env.USING_COVERAGE, matrix.python-version)" diff --git a/changelog.d/408.deprecation.rst b/changelog.d/408.deprecation.rst index f2e3cbb9c..e1beea7c7 100644 --- a/changelog.d/408.deprecation.rst +++ b/changelog.d/408.deprecation.rst @@ -6,3 +6,6 @@ If this is a problem for you for some reason, please report it to our bug tracke The old ``attr`` namespace isn't going anywhere and its defaults are not changing – this is a purely additive measure. Please check out the linked issue for more details. + +These new APIs have been added *provisionally* as part of `#666 `_ so you can try them out today and provide feedback. +Learn more in the `API docs `_. diff --git a/changelog.d/666.change.rst b/changelog.d/666.change.rst new file mode 100644 index 000000000..747612b08 --- /dev/null +++ b/changelog.d/666.change.rst @@ -0,0 +1,8 @@ +**Provisional** APIs called ``attr.define()``, ``attr.mutable()``, and ``attr.frozen()`` have been added. + +They are only available on Python 3.6 and later, and call ``attr.s()`` with different default values. + +If nothing comes up, they will become the official way for creating classes in 20.2.0 (see above). + +**Please note** that it may take some time until mypy – and other tools that have dedicated support for ``attrs`` – recognize these new APIs. +Please **do not** open issues on our bug tracker, there is nothing we can do about it. diff --git a/conftest.py b/conftest.py index b83f69a5f..7d3d1f802 100644 --- a/conftest.py +++ b/conftest.py @@ -16,5 +16,9 @@ def pytest_configure(config): collect_ignore = [] if sys.version_info[:2] < (3, 6): collect_ignore.extend( - ["tests/test_annotations.py", "tests/test_init_subclass.py"] + [ + "tests/test_annotations.py", + "tests/test_init_subclass.py", + "tests/test_next_gen.py", + ] ) diff --git a/docs/api.rst b/docs/api.rst index 18a17c50f..7797e6505 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -16,6 +16,12 @@ What follows is the API explanation, if you'd like a more hands-on introduction, Core ---- + +.. warning:: + As of ``attrs`` 20.1.0, it also ships with a bunch of provisional APIs that are intended to become the main way of defining classes in the future. + + Please have a look at :ref:`prov`. + .. autodata:: attr.NOTHING .. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None) @@ -580,6 +586,47 @@ These are helpers that you can use together with `attr.s`'s and `attr.ib`'s ``on N.B. Please use `attr.s`'s *frozen* argument to freeze whole classes; it is more efficient. +.. _prov: + +Provisional APIs +---------------- + +These are Python 3.6 and later-only, keyword-only, and **provisional** APIs that call `attr.s` with different default values. + +The most notable differences are: + +- automatically detect whether or not *auto_attribs* should be `True` +- *slots=True* (see :term:`slotted classes`) +- *auto_exc=True* +- *auto_detect=True* +- *eq=True*, but *order=False* +- Validators run when you set an attribute (*on_setattr=attr.setters.validate*). +- Some options that aren't relevant to Python 3 have been dropped. + +Please note that these are *defaults* and you're free to override them, just like before. + +---- + +Their behavior is scheduled to become part of the upcoming ``import attrs`` that will introduce a new namespace with nicer names and nicer defaults (see `#408 `_ and `#487 `_). + +Therefore your constructive feedback in the linked issues above is strongly encouraged! + +.. note:: + Provisional doesn't mean we will remove it (although it will be deprecated once the final form is released), but that it might change if we receive relevant feedback. + + `attr.s` and `attr.ib` (and their serious business cousins) aren't going anywhere. + The new APIs build on top of them. + +.. autofunction:: attr.define +.. function:: attr.mutable(same_as_define) + + Alias for `attr.define`. + +.. function:: attr.frozen(same_as_define) + + Behaves the same as `attr.define` but sets *frozen=True* and *on_setattr=None*. + + Deprecated APIs --------------- diff --git a/src/attr/__init__.py b/src/attr/__init__.py index fa9414ee9..572daaff9 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division, print_function +import sys + from functools import partial from . import converters, exceptions, filters, setters, validators @@ -39,7 +41,6 @@ ib = attr = attrib dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) - __all__ = [ "Attribute", "Factory", @@ -68,3 +69,8 @@ "validate", "validators", ] + +if sys.version_info[:2] >= (3, 6): + from ._next_gen import define, frozen, mutable + + __all__.extend((define, frozen, mutable)) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 5f3b55344..f3d6bb669 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -227,6 +227,53 @@ def attrs( getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Callable[[_C], _C]: ... +@overload +def define( + maybe_cls: _C, + *, + these: Optional[Dict[str, Any]] = ..., + repr: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _C: ... +@overload +def define( + maybe_cls: None = ..., + *, + these: Optional[Dict[str, Any]] = ..., + repr: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Callable[[_C], _C]: ... + +mutable = define +frozen = define # they differ only in their defaults # TODO: add support for returning NamedTuple from the mypy plugin class _Fields(Tuple[Attribute[Any], ...]): diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py new file mode 100644 index 000000000..8312d2157 --- /dev/null +++ b/src/attr/_next_gen.py @@ -0,0 +1,86 @@ +""" +This is a Python 3.6 and later-only, keyword-only, and **provisional** API that +calls `attr.s` with different default values. + +Provisional APIs that shall become "import attrs" one glorious day. +""" + +from functools import partial + +from attr.exceptions import UnannotatedAttributeError + +from . import setters +from ._make import attrs + + +def define( + maybe_cls=None, + *, + these=None, + repr=None, + hash=None, + init=None, + slots=True, + frozen=False, + weakref_slot=True, + str=False, + auto_attribs=None, + kw_only=False, + cache_hash=False, + auto_exc=True, + eq=True, + order=False, + auto_detect=True, + getstate_setstate=None, + on_setattr=setters.validate +): + r""" + The only behavioral difference is the handling of the *auto_attribs* + option: + + :param Optional[bool] auto_attribs: If set to `True` or `False`, it behaves + exactly like `attr.s`. If left `None`, `attr.s` will try to guess: + + 1. If all attributes are annotated and no `attr.ib` is found, it assumes + *auto_attribs=True*. + 2. Otherwise it assumes *auto_attribs=False* and tries to collect + `attr.ib`\ s. + + + .. versionadded:: 20.1.0 + """ + + def do_it(auto_attribs): + return attrs( + maybe_cls=maybe_cls, + these=these, + repr=repr, + hash=hash, + init=init, + slots=slots, + frozen=frozen, + weakref_slot=weakref_slot, + str=str, + auto_attribs=auto_attribs, + kw_only=kw_only, + cache_hash=cache_hash, + auto_exc=auto_exc, + eq=eq, + order=order, + auto_detect=auto_detect, + collect_by_mro=True, + getstate_setstate=getstate_setstate, + on_setattr=on_setattr, + ) + + if auto_attribs is not None: + return do_it(auto_attribs) + + try: + return do_it(True) + except UnannotatedAttributeError: + return do_it(False) + + +mutable = define +frozen = partial(define, frozen=True, on_setattr=None) diff --git a/tests/test_next_gen.py b/tests/test_next_gen.py new file mode 100644 index 000000000..c1a85a922 --- /dev/null +++ b/tests/test_next_gen.py @@ -0,0 +1,135 @@ +""" +Python 3-only integration tests for provisional next generation APIs. +""" + +import pytest + +import attr + + +@attr.define +class C: + x: str + y: int + + +class TestNextGen: + def test_simple(self): + """ + Instantiation works. + """ + C("1", 2) + + def test_no_slots(self): + """ + slots can be deactivated. + """ + + @attr.define(slots=False) + class NoSlots: + x: int + + ns = NoSlots(1) + + assert {"x": 1} == getattr(ns, "__dict__") + + def test_validates(self): + """ + Validators at __init__ and __setattr__ work. + """ + + @attr.define + class Validated: + x: int = attr.ib(validator=attr.validators.instance_of(int)) + + v = Validated(1) + + with pytest.raises(TypeError): + Validated(None) + + with pytest.raises(TypeError): + v.x = "1" + + def test_no_order(self): + """ + Order is off by default but can be added. + """ + with pytest.raises(TypeError): + C("1", 2) < C("2", 3) + + @attr.define(order=True) + class Ordered: + x: int + + assert Ordered(1) < Ordered(2) + + def test_override_auto_attribs_true(self): + """ + Don't guess if auto_attrib is set explicitly. + + Having an unannotated attr.ib fails. + """ + with pytest.raises(attr.exceptions.UnannotatedAttributeError): + + @attr.define(auto_attribs=True) + class ThisFails: + x = attr.ib() + y: int + + def test_override_auto_attribs_false(self): + """ + Don't guess if auto_attrib is set explicitly. + + Annotated fields that don't carry an attr.ib are ignored. + """ + + @attr.define(auto_attribs=False) + class NoFields: + x: int + y: int + + assert NoFields() == NoFields() + + def test_auto_attribs_detect(self): + """ + define correctly detects if a class lacks type annotations. + """ + + @attr.define + class OldSchool: + x = attr.ib() + + assert OldSchool(1) == OldSchool(1) + + def test_exception(self): + """ + Exceptions are detected and correctly handled. + """ + + @attr.define + class E(Exception): + msg: str + other: int + + with pytest.raises(E) as ei: + raise E("yolo", 42) + + e = ei.value + + assert ("yolo", 42) == e.args + assert "yolo" == e.msg + assert 42 == e.other + + def test_frozen(self): + """ + attr.frozen freezes classes. + """ + + @attr.frozen + class F: + x: str + + f = F(1) + + with pytest.raises(attr.exceptions.FrozenInstanceError): + f.x = 2 diff --git a/tests/typing_example.py b/tests/typing_example.py index 01c061049..1f7854a2c 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -207,3 +207,30 @@ class ValidatedSetter: # def __init__(self, x: int): # self.x = x + +# Provisional APIs +@attr.define(order=True) +class NGClass: + x: int + + +# XXX: needs support in mypy +# ngc = NGClass(1) + + +@attr.mutable(slots=False) +class NGClass2: + x: int + + +# XXX: needs support in mypy +# ngc2 = NGClass2(1) + + +@attr.frozen(str=True) +class NGFrozen: + x: int + + +# XXX: needs support in mypy +# ngf = NGFrozen(1)