Repository: python-attrs/attrs (Python)

Issue:
Using field transformer breaks integration with hypothesis
Adding field transformer according to [documentation](https://www.attrs.org/en/stable/extending.html#automatic-field-transformation-and-modification) (i.e. as a function of type `Callable[[type, list[attr.Attribute[Any]]], list[attr.Attribute[Any]]]`)
breaks `attrs` integration with hypothesis, because attributes transformed with such function are not casted to generated `AttrsClass`, but remains just an list of attributes ([here](https://github.com/python-attrs/attrs/blob/9709dd82e1cc0f5b783f4127e87498dfdd6a224a/src/attr/_make.py#L602))

This causes error in hypothesis [here](https://github.com/HypothesisWorks/hypothesis/blob/3d5761ed9254b39260f39cf52bad1293ccf733ab/hypothesis-python/src/hypothesis/strategies/_internal/attrs.py#L33), because `fields` is an list, not an `AttrsClass` instance, and therefore `getattr(fields, name)` raises an `AttributeError`.

Easy workaround for that is just to create an `AttrsClass` again inside field transformer, like so:

```
def transformer(cls: type, fields: list[attr.Attribute[Any]]) -> list[attr.Attribute[Any]]:
    attr_names = [f.name for f in fields]
    AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names)
    
    ... do something with fields ...

    return AttrsClass(fields)
```

...but it's not very handy nor pretty, and messes with typing. So, I would very thankful if it could be resolved within `attrs` (it would be quite easy, I guess).

BTW. I love your package, awesome job! Cheers!


Discussion:
@sscherfke? 😇
I will take a look, but I can’t tell when. I’m currently quite busy with work. :)



Patch:
diff --git a/changelog.d/824.changes.rst b/changelog.d/824.changes.rst
new file mode 100644
index 000000000..4d3e6acda
--- /dev/null
+++ b/changelog.d/824.changes.rst
@@ -0,0 +1,1 @@
+Attributes transformed via ``field_transformer`` are wrapped with ``AttrsClass`` again.
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 95a37ea64..ac6bbc10e 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -581,15 +581,11 @@ def _transform_attrs(
             cls, {a.name for a in own_attrs}
         )
 
-    attr_names = [a.name for a in base_attrs + own_attrs]
-
-    AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names)
-
     if kw_only:
         own_attrs = [a.evolve(kw_only=True) for a in own_attrs]
         base_attrs = [a.evolve(kw_only=True) for a in base_attrs]
 
-    attrs = AttrsClass(base_attrs + own_attrs)
+    attrs = base_attrs + own_attrs
 
     # Mandatory vs non-mandatory attr order only matters when they are part of
     # the __init__ signature and when they aren't kw_only (which are moved to
@@ -608,7 +604,13 @@ def _transform_attrs(
 
     if field_transformer is not None:
         attrs = field_transformer(cls, attrs)
-    return _Attributes((attrs, base_attrs, base_attr_map))
+
+    # Create AttrsClass *after* applying the field_transformer since it may
+    # add or remove attributes!
+    attr_names = [a.name for a in attrs]
+    AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names)
+
+    return _Attributes((AttrsClass(attrs), base_attrs, base_attr_map))
 
 
 if PYPY:


Test patch:
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
index 5fcb10047..7e5ac3d9e 100644
--- a/tests/test_hooks.py
+++ b/tests/test_hooks.py
@@ -117,6 +117,22 @@ class Sub(Base):
 
         assert attr.asdict(Sub(2)) == {"y": 2}
 
+    def test_attrs_attrclass(self):
+        """
+        The list of attrs returned by a field_transformer is converted to
+        "AttrsClass" again.
+
+        Regression test for #821.
+        """
+
+        @attr.s(auto_attribs=True, field_transformer=lambda c, a: list(a))
+        class C:
+            x: int
+
+        fields_type = type(attr.fields(C))
+        assert fields_type.__name__ == "CAttributes"
+        assert issubclass(fields_type, tuple)
+
 
 class TestAsDictHook:
     def test_asdict(self):