Repository: python-attrs/attrs
Pull request #666 (2020-08-14 08:10:35)

Add attr.define/mutable/frozen
Prepare for import attrs y'all!

This is basically what we kinda agreed on in #408 and #487.

I'm open to comments and suggestions.

Also suggestions how to supply type stubs without a major copy/paste operation. 😳

lig: My wishes:
* `kw_only` by default
* `auto_attribs` by default
* `attrs.Field()` for attributes
* `attrs.Class()` for class decorators
* allow to inherit something like `attrs.BaseClass` to be able to use `isinstance(obj, attrs.BaseClass)` later (basically allow to detect that we have attrs class)
* recursive `attrs.from_dict()`
* transparent builder API to simplify third party tools integration like converters between ORM models and attrs, serializer, etc
* regarding defaults, maybe allow building your own factory which provides required defaults to reuse it later.
hynek: To be clear, I'm only looking for stuff that we might paint ourselves into the corner. Most of what you listed can be fixed at any time.

- kw_only=True is not happening, because I had it in [characteristic](https://github.com/hynek/characteristic) and people (including myself) hated it.
- ✔︎
- not sure what you mean? Just a different name? I'm indeed thinking to go for `attrs.field` or `attrs.Field` in the next APIs.
- you mean `@attrs.Class\nclass Foo`? After long deliberation on #408  it's gonna be `attrs.define`. Class class is repetitive.
- uh, `attr.has`?
- that's too complex, so we've delegated that to other packages like cattrs and try to help them get better at it in #653 
- that's interesting but out of scope for this issue
- you can do that with `functools.partial`. You just have to trick mypy into recognizing it but we've just merged some docs on that in #650 
lig: > * kw_only=True is not happening, because I had it in [characteristic](https://github.com/hynek/characteristic) and people (including myself) hated it.

:( it is how it is then. anyway, everybody needs different defaults

> * not sure what you mean? Just a different name? I'm indeed thinking to go for `attrs.field` or `attrs.Field` in the next APIs.

well, kinda. if it will be `import attrs` then `attrs.ib` doesn't work anymore. So, if `import attr` works as before then new API with `import attrs` needs something new and explicit.

> * you mean `@attrs.Class\nclass Foo`? After long deliberation on #408  it's gonna be `attrs.define`. Class class is repetitive.

Doesn't matter much, define works as well. `attrs.s` looks kinda "meh"

> * uh, `attr.has`?

Nice. Never noticed it. Maybe, missed in the docs and then didn't recognize it with autocomplete. The name feels a bot misleading to guess what it does straight away. Thanks for educating me on this:)

> * that's too complex, so we've delegated that to other packages like cattrs and try to help them get better at it in #653

Well, maybe the next point will help with it. However, it feels like it's not that complex and it would fully complement `asdict`. It's your call, no pressure here. `cattrs` worked for me.

> * that's interesting but out of scope for this issue

Fair enough. Shall I describe the idea in a separate issue?

> * you can do that with `functools.partial`. You just have to trick mypy into recognizing it but we've just merged some docs on that in #650

Making this work with fewer tricks would help. Docs are good, though:)


Thanks and keep up the good work!
hynek: > > * not sure what you mean? Just a different name? I'm indeed thinking to go for `attrs.field` or `attrs.Field` in the next APIs.

> well, kinda. if it will be `import attrs` then `attrs.ib` doesn't work anymore. So, if `import attr` works as before then new API with `import attrs` needs something new and explicit.

Yeah, it's prob gonna be `attrs.field`.

> > * that's interesting but out of scope for this issue 

> Fair enough. Shall I describe the idea in a separate issue?

Sure!

> Thanks and keep up the good work!

💛
euresti: This is neat. I will have to spend some time fixing the mypy plugin to get these to work.  
hynek: > This is neat.

Not sure if sarcasm. ;)

> I will have to spend some time fixing the mypy plugin to get these to work.

Do the annotations look OK so far? I don't think I can do anymore than release and wait for mypy, correct?
euresti: Not sarcasm, I'm excited to not have to confuse people in my team with `@dataclass` (no, not that kind of dataclass)


hynek: I guess there's no point in procrastinating on this any further…merging and if nobody yells at me, I'll release 20.1 tomorrow.

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index d97a886..0722007 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 f2e3cbb..e1beea7 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 <https://github.com/python-attrs/attrs/pull/666>`_ so you can try them out today and provide feedback.
+Learn more in the `API docs <https://www.attrs.org/en/stable/api.html#provisional-apis>`_.
diff --git a/changelog.d/666.change.rst b/changelog.d/666.change.rst
new file mode 100644
index 0000000..747612b
--- /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/docs/api.rst b/docs/api.rst
index 18a17c5..7797e65 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 <https://github.com/python-attrs/attrs/issues/408>`_ and `#487 <https://github.com/python-attrs/attrs/issues/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 fa9414e..572daaf 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 @@ s = attributes = attrs
 ib = attr = attrib
 dataclass = partial(attrs, auto_attribs=True)  # happy Easter ;)
 
-
 __all__ = [
     "Attribute",
     "Factory",
@@ -68,3 +69,8 @@ __all__ = [
     "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 5f3b553..f3d6bb6 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 0000000..8312d21
--- /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)


Test patch:
diff --git a/conftest.py b/conftest.py
index b83f69a..7d3d1f8 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/tests/test_next_gen.py b/tests/test_next_gen.py
new file mode 100644
index 0000000..c1a85a9
--- /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 01c0610..1f7854a 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)