Repository: python-attrs/attrs
Pull request #1203 (2023-11-13 19:44:14)

Add optional namespace arguments for `make_class`
Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

# Summary

Add an optional `namespaces` argument to `attr.make_class` to allow users to inject addtional namespaces attributes into newly created attrs class.

I think this is pretty useful for cases where a derivation of the base class is needed with additional functionalities. Since `make_class` use `types.new_class` under the hood this is possible.

Currently, if users want to derivate from a attrs classes, one might do:

```python
import attr

@attr.define
class Base:
    def a(self): ...

def b(self: type[Base], something_else: int) -> int: return something_else + 1

new_class = types.new_class("NewClass", (Base,), exec_body=lambda ns: ns.update({"b": b}))
```

This will lose types information and therefore users might need to provide additional stubs for the new class. With this PR, users can do:

```python
import attrs

@attr.define
class Base:
    def a(self): ...

def b(self: type[Base], something_else: int) -> int: return something_else + 1

new_class = attr.make_class("NewClass", bases=(Base,), namespaces={"b": b})
```

Which is nice, but might not be a good idea.

# Pull Request Check List

<!--
This is just a friendly reminder about the most common mistakes.
Please make sure that you tick all boxes.
But please read our [contribution guide](https://github.com/python-attrs/attrs/blob/main/.github/CONTRIBUTING.md) at least once, it will save you unnecessary review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing left to do.
If your pull request is a documentation fix or a trivial typo, feel free to delete the whole thing.
-->

- [x] Do **not** open pull requests from your `main` branch – **use a separate branch**!
  - There's a ton of footguns waiting if you don't heed this warning. You can still go back to your project, create a branch from your main branch, push it, and open the pull request from the new branch.
  - This is not a pre-requisite for your your pull request to be accepted, but **you have been warned**.
- [x] Added **tests** for changed code.
      Our CI fails if coverage is not 100%.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in `.pyi`).
  - [ ] ...and used in the stub test file `tests/typing_example.py`.
  - [x] If they've been added to `attr/__init__.pyi`, they've _also_ been re-imported in `attrs/__init__.pyi`.
- [x] Updated **documentation** for changed code.
  - [x] New functions/classes have to be added to `docs/api.rst` by hand.
  - [ ] Changes to the signature of `@attr.s()` have to be added by hand too.
  - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).
        The next version is the second number in the current release + 1.
        The first number represents the current year.
        So if the current version on PyPI is 22.2.0, the next version is gonna be 22.3.0.
        If the next version is the first in the new year, it'll be 23.1.0.
- [x] Documentation in `.rst` and `.md` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [ ] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d).
- [ ] Consider granting [push permissions to the PR branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork), so maintainers can fix minor issues themselves without pestering you.

<!--
If you have *any* questions to *any* of the points above, just **submit and ask**!
This checklist is here to *help* you, not to deter you from contributing!
-->


hynek: I think this is an interesting idea as per our ideal to be a class construction kit!


But I don't think *namespaces* is a good term for what it does. Wanna brainstorm something more obvious? Maybe `class_body`?
aarnphm: > I think this is an interesting idea as per our ideal to be a class construction kit!
> 
> But I don't think _namespaces_ is a good term for what it does. Wanna brainstorm something more obvious? Maybe `class_body`?

I think class_body makes sense. My guess that from a Python developer perspective, this would infer that attributes here are class attributes.

Though I'm not quite sure if users implement a `__class_getitem__` override, how would that work?

`__class_getitem__` would probably have access to all transpiled attributes instead of the _CountingAttr?
hynek: > > I think this is an interesting idea as per our ideal to be a class construction kit!
> > But I don't think _namespaces_ is a good term for what it does. Wanna brainstorm something more obvious? Maybe `class_body`?
> 
> I think class_body makes sense. My guess that from a Python developer perspective, this would infer that attributes here are class attributes.

I mean… they are? Am I misunderstanding what your code is doing? 😅

> Though I'm not quite sure if users implement a `__class_getitem__` override, how would that work?

Can you elaborate what you mean by that please?

> `__class_getitem__` would probably have access to all transpiled attributes instead of the _CountingAttr?
> 
> Oh I forgot to mention that for type inference, mypy plugin for `attr.make_class` will need to be implemented as well, otherwise `B.echo` will raise a type error.

That sounds like a @Tinche problem. 😇
aarnphm: > I think class_body makes sense. My guess that from a Python developer perspective, this would infer that attributes here are class attributes.
>
> I mean... they are? Am I misunderstanding what your code is doing? 😅

Oh, I think we are aligned on this.

> Though I'm not quite sure if users implement a `__class_getitem__` override, how would that work?
>
> Can you elaborate what you mean by that please?

Yes, so for the following example

```python
import attr

@attr.define
class Base:
    x: int
    y: t.List[int] = attr.field()

def __class_getitem_override(cls, item): # what is the type of item here?
    if item.__name__ == 'y': return [1,2,3]
    else: return cls.__getitem__(item)

new_class = attr.make_class("NewClass", bases=(Base,), namespaces={"__class_getitem__": __class_getitem_override})
```

There is no preventing users from doing something like this right? What should be the type of `item` here? In true PEP560 fashion, this should be a class attributes, which means it is the _CountingAttr type here instead of `attr.Attribute`.


hynek: Oh god TIL about `__class_getitem__`. Uhh… Tiiiiiin?
aarnphm: > Oh god TIL about `__class_getitem__`. Uhh… Tiiiiiin?

This is the one concern I have when thinking about this feature, so probably will def need a discussion about this.
hynek: OK since this seems to be a somewhat stalling issue: is your PR in any way special to `__class_getitem__`?

From reading the docs around it, I understand it's a fallback if there's no `__getitem__` and if the user implemented it. But, that doesn't seem to be our business? We don't implement that method, nor do we deal with `__getitem__` – so the user can do whatever?

Like we try hard to not create attractive nuisances, but we also try to not be too limiting.
aarnphm: Ye I think it should be fine. So that is probably not an issue. This one is ready to merge.

Patch:
diff --git a/changelog.d/1203.change.md b/changelog.d/1203.change.md
new file mode 100644
index 0000000..38c5c9f
--- /dev/null
+++ b/changelog.d/1203.change.md
@@ -0,0 +1,2 @@
+Added *class_body* argument to `attrs.make_class()` to provide additional attributes for newly created classes.
+It is, for example, now possible to attach methods.
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index 0f64150..37a2087 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -490,6 +490,7 @@ def make_class(
     name: str,
     attrs: Union[List[str], Tuple[str, ...], Dict[str, Any]],
     bases: Tuple[type, ...] = ...,
+    class_body: Optional[Dict[str, Any]] = ...,
     repr_ns: Optional[str] = ...,
     repr: bool = ...,
     cmp: Optional[_EqOrderType] = ...,
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 9dd0e2e..8a4e658 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -2867,7 +2867,9 @@ _f = [
 Factory = _add_hash(_add_eq(_add_repr(Factory, attrs=_f), attrs=_f), attrs=_f)
 
 
-def make_class(name, attrs, bases=(object,), **attributes_arguments):
+def make_class(
+    name, attrs, bases=(object,), class_body=None, **attributes_arguments
+):
     r"""
     A quick way to create a new class called *name* with *attrs*.
 
@@ -2883,6 +2885,8 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments):
 
     :param tuple bases: Classes that the new class will subclass.
 
+    :param dict class_body: An optional dictionary of class attributes for the new class.
+
     :param attributes_arguments: Passed unmodified to `attr.s`.
 
     :return: A new class with *attrs*.
@@ -2890,6 +2894,7 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments):
 
     .. versionadded:: 17.1.0 *bases*
     .. versionchanged:: 18.1.0 If *attrs* is ordered, the order is retained.
+    .. versionchanged:: 23.2.0 *class_body*
     """
     if isinstance(attrs, dict):
         cls_dict = attrs
@@ -2904,6 +2909,8 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments):
     user_init = cls_dict.pop("__init__", None)
 
     body = {}
+    if class_body is not None:
+        body.update(class_body)
     if pre_init is not None:
         body["__attrs_pre_init__"] = pre_init
     if post_init is not None:


Test patch:
diff --git a/tests/test_make.py b/tests/test_make.py
index 45c35df..19f7a4c 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -1106,6 +1106,18 @@ class TestMakeClass:
         assert D in cls.__mro__
         assert isinstance(cls(), D)
 
+    def test_additional_class_body(self):
+        """
+        Additional class_body is added to newly created class.
+        """
+
+        def echo_func(cls, *args):
+            return args
+
+        cls = make_class("C", {}, class_body={"echo": classmethod(echo_func)})
+
+        assert ("a", "b") == cls.echo("a", "b")
+
     def test_clean_class(self, slots):
         """
         Attribute definitions do not appear on the class body.