From c2824ac30f315bfbc7398960c6831d68ecc9edb4 Mon Sep 17 00:00:00 2001 From: Victor Song Date: Fri, 29 Sep 2023 01:47:51 -0500 Subject: [PATCH] Pass args from `__init__` to `__attrs_pre_init__` if requested (#1187) * Pass args from `__init__` to `__attrs_pre_init__` if requested Detect if `__attrs_pre_init__` has arguments besides `self` using `inspect.signature`. If so, pass `__attrs_pre_init__` the same arguments that `__init__` (or `__attrs_init__`) expects. * Add changelog entry for `__attrs_pre_init__` args changes * Don't use monkeypatch in new code * Clarify docs * Missed one monkeypatching --------- Co-authored-by: Hynek Schlawack --- changelog.d/1187.change.md | 2 ++ docs/init.md | 3 +- src/attr/_make.py | 26 ++++++++++++++ tests/test_dunders.py | 8 ++++- tests/test_make.py | 74 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1187.change.md diff --git a/changelog.d/1187.change.md b/changelog.d/1187.change.md new file mode 100644 index 00000000..b06f115a --- /dev/null +++ b/changelog.d/1187.change.md @@ -0,0 +1,2 @@ +If *attrs* detects that `__attrs_pre_init__` accepts more than just `self`, it will call it with the same arguments as `__init__` was called. +This allows you to, for example, pass arguments to `super().__init__()`. diff --git a/docs/init.md b/docs/init.md index 2320294f..3b2e6653 100644 --- a/docs/init.md +++ b/docs/init.md @@ -357,7 +357,8 @@ However, sometimes you need to do that one quick thing before or after your clas For that purpose, *attrs* offers the following options: - `__attrs_pre_init__` is automatically detected and run *before* *attrs* starts initializing. - This is useful if you need to inject a call to `super().__init__()`. + If `__attrs_pre_init__` takes more than the `self` argument, the *attrs*-generated `__init__` will call it with the same arguments it received itself. + This is useful if you need to inject a call to `super().__init__()` -- with or without arguments. - `__attrs_post_init__` is automatically detected and run *after* *attrs* is done initializing your instance. This is useful if you want to derive some attribute from others or perform some kind of validation over the whole instance. diff --git a/src/attr/_make.py b/src/attr/_make.py index 1c58eaf5..eb7a1fec 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -3,6 +3,7 @@ import contextlib import copy import enum +import inspect import linecache import sys import types @@ -624,6 +625,7 @@ class _ClassBuilder: "_delete_attribs", "_frozen", "_has_pre_init", + "_pre_init_has_args", "_has_post_init", "_is_exc", "_on_setattr", @@ -670,6 +672,13 @@ class _ClassBuilder: self._weakref_slot = weakref_slot self._cache_hash = cache_hash self._has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) + self._pre_init_has_args = False + if self._has_pre_init: + # Check if the pre init method has more arguments than just `self` + # We want to pass arguments if pre init expects arguments + pre_init_func = cls.__attrs_pre_init__ + pre_init_signature = inspect.signature(pre_init_func) + self._pre_init_has_args = len(pre_init_signature.parameters) > 1 self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False)) self._delete_attribs = not bool(these) self._is_exc = is_exc @@ -974,6 +983,7 @@ class _ClassBuilder: self._cls, self._attrs, self._has_pre_init, + self._pre_init_has_args, self._has_post_init, self._frozen, self._slots, @@ -1000,6 +1010,7 @@ class _ClassBuilder: self._cls, self._attrs, self._has_pre_init, + self._pre_init_has_args, self._has_post_init, self._frozen, self._slots, @@ -1984,6 +1995,7 @@ def _make_init( cls, attrs, pre_init, + pre_init_has_args, post_init, frozen, slots, @@ -2027,6 +2039,7 @@ def _make_init( frozen, slots, pre_init, + pre_init_has_args, post_init, cache_hash, base_attr_map, @@ -2107,6 +2120,7 @@ def _attrs_to_init_script( frozen, slots, pre_init, + pre_init_has_args, post_init, cache_hash, base_attr_map, @@ -2361,11 +2375,23 @@ def _attrs_to_init_script( lines.append(f"BaseException.__init__(self, {vals})") args = ", ".join(args) + pre_init_args = args if kw_only_args: args += "%s*, %s" % ( ", " if args else "", # leading comma ", ".join(kw_only_args), # kw_only args ) + pre_init_kw_only_args = ", ".join( + ["%s=%s" % (kw_arg, kw_arg) for kw_arg in kw_only_args] + ) + pre_init_args += ( + ", " if pre_init_args else "" + ) # handle only kwargs and no regular args + pre_init_args += pre_init_kw_only_args + + if pre_init and pre_init_has_args: + # If pre init method has arguments, pass same arguments as `__init__` + lines[0] = "self.__attrs_pre_init__(%s)" % pre_init_args return ( "def %s(self, %s):\n %s\n" diff --git a/tests/test_dunders.py b/tests/test_dunders.py index 29673c4a..d0d289d8 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -6,6 +6,7 @@ Tests for dunder methods from `attrib._make`. import copy +import inspect import pickle import pytest @@ -84,10 +85,15 @@ def _add_init(cls, frozen): This function used to be part of _make. It wasn't used anymore however the tests for it are still useful to test the behavior of _make_init. """ + has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) + cls.__init__ = _make_init( cls, cls.__attrs_attrs__, - getattr(cls, "__attrs_pre_init__", False), + has_pre_init, + len(inspect.signature(cls.__attrs_pre_init__).parameters) > 1 + if has_pre_init + else False, getattr(cls, "__attrs_post_init__", False), frozen, _is_slot_cls(cls), diff --git a/tests/test_make.py b/tests/test_make.py index a00922c2..286f18af 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -613,21 +613,89 @@ class TestAttributes: assert C.D.__qualname__ == C.__qualname__ + ".D" @pytest.mark.parametrize("with_validation", [True, False]) - def test_pre_init(self, with_validation, monkeypatch): + def test_pre_init(self, with_validation): """ Verify that __attrs_pre_init__ gets called if defined. """ - monkeypatch.setattr(_config, "_run_validators", with_validation) @attr.s class C: def __attrs_pre_init__(self2): self2.z = 30 - c = C() + try: + attr.validators.set_disabled(not with_validation) + c = C() + finally: + attr.validators.set_disabled(False) assert 30 == getattr(c, "z", None) + @pytest.mark.parametrize("with_validation", [True, False]) + def test_pre_init_args(self, with_validation): + """ + Verify that __attrs_pre_init__ gets called with extra args if defined. + """ + + @attr.s + class C: + x = attr.ib() + + def __attrs_pre_init__(self2, x): + self2.z = x + 1 + + try: + attr.validators.set_disabled(not with_validation) + c = C(x=10) + finally: + attr.validators.set_disabled(False) + + assert 11 == getattr(c, "z", None) + + @pytest.mark.parametrize("with_validation", [True, False]) + def test_pre_init_kwargs(self, with_validation): + """ + Verify that __attrs_pre_init__ gets called with extra args and kwargs if defined. + """ + + @attr.s + class C: + x = attr.ib() + y = attr.field(kw_only=True) + + def __attrs_pre_init__(self2, x, y): + self2.z = x + y + 1 + + try: + attr.validators.set_disabled(not with_validation) + c = C(10, y=11) + finally: + attr.validators.set_disabled(False) + + assert 22 == getattr(c, "z", None) + + @pytest.mark.parametrize("with_validation", [True, False]) + def test_pre_init_kwargs_only(self, with_validation): + """ + Verify that __attrs_pre_init__ gets called with extra kwargs only if + defined. + """ + + @attr.s + class C: + y = attr.field(kw_only=True) + + def __attrs_pre_init__(self2, y): + self2.z = y + 1 + + try: + attr.validators.set_disabled(not with_validation) + c = C(y=11) + finally: + attr.validators.set_disabled(False) + + assert 12 == getattr(c, "z", None) + @pytest.mark.parametrize("with_validation", [True, False]) def test_post_init(self, with_validation, monkeypatch): """