diff --git a/changelog.d/450.change.rst b/changelog.d/450.change.rst new file mode 100644 index 00000000..6f1f6203 --- /dev/null +++ b/changelog.d/450.change.rst @@ -0,0 +1 @@ +Allow `init=False` arguments after `kw_only` arguments. diff --git a/src/attr/_make.py b/src/attr/_make.py index f7fd05e7..6d72df4a 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -409,12 +409,11 @@ def _transform_attrs(cls, these, auto_attribs, kw_only): a.kw_only is False ): had_default = True - if was_kw_only is True and a.kw_only is False: + if was_kw_only is True and a.kw_only is False and a.init is True: raise ValueError( "Non keyword-only attributes are not allowed after a " - "keyword-only attribute. Attribute in question: {a!r}".format( - a=a - ) + "keyword-only attribute (unless they are init=False). " + "Attribute in question: {a!r}".format(a=a) ) if was_kw_only is False and a.init is True and a.kw_only is True: was_kw_only = True diff --git a/tests/test_make.py b/tests/test_make.py index 8bd2d2b9..d02c1242 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -705,7 +705,8 @@ class TestKeywordOnlyAttributes(object): assert ( "Non keyword-only attributes are not allowed after a " - "keyword-only attribute. Attribute in question: Attribute" + "keyword-only attribute (unless they are init=False). " + "Attribute in question: Attribute" "(name='y', default=NOTHING, validator=None, repr=True, " "cmp=True, hash=None, init=True, metadata=mappingproxy({}), " "type=None, converter=None, kw_only=False)", @@ -771,6 +772,62 @@ class TestKeywordOnlyAttributes(object): assert c.x == 0 assert c.y == 1 + def test_init_false_attribute_after_keyword_attribute(self): + """ + A positional attribute cannot follow a `kw_only` attribute, + but an `init=False` attribute can because it won't appear + in `__init__` + """ + + @attr.s + class KwArgBeforeInitFalse: + kwarg = attr.ib(kw_only=True) + non_init_function_default = attr.ib(init=False) + non_init_keyword_default = attr.ib( + init=False, default="default-by-keyword" + ) + + @non_init_function_default.default + def _init_to_init(self): + return self.kwarg + "b" + + c = KwArgBeforeInitFalse(kwarg="a") + + assert c.kwarg == "a" + assert c.non_init_function_default == "ab" + assert c.non_init_keyword_default == "default-by-keyword" + + def test_init_false_attribute_after_keyword_attribute_with_inheritance( + self + ): + """ + A positional attribute cannot follow a `kw_only` attribute, + but an `init=False` attribute can because it won't appear + in `__init__`. This test checks that we allow this + even when the `kw_only` attribute appears in a parent class + """ + + @attr.s + class KwArgBeforeInitFalseParent: + kwarg = attr.ib(kw_only=True) + + @attr.s + class KwArgBeforeInitFalseChild(KwArgBeforeInitFalseParent): + non_init_function_default = attr.ib(init=False) + non_init_keyword_default = attr.ib( + init=False, default="default-by-keyword" + ) + + @non_init_function_default.default + def _init_to_init(self): + return self.kwarg + "b" + + c = KwArgBeforeInitFalseChild(kwarg="a") + + assert c.kwarg == "a" + assert c.non_init_function_default == "ab" + assert c.non_init_keyword_default == "default-by-keyword" + @pytest.mark.skipif(not PY2, reason="PY2-specific keyword-only error behavior") class TestKeywordOnlyAttributesOnPy2(object):