From 342644eb9aa90366564b3b44ec36bc0470fac973 Mon Sep 17 00:00:00 2001 From: Victor Song Date: Sun, 17 Sep 2023 23:29:15 -0500 Subject: [PATCH 1/5] 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. --- docs/init.md | 1 + src/attr/_make.py | 26 ++++++++++++++++++++ tests/test_dunders.py | 8 ++++++- tests/test_make.py | 55 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/docs/init.md b/docs/init.md index 2320294f8..fbb20ffbc 100644 --- a/docs/init.md +++ b/docs/init.md @@ -358,6 +358,7 @@ 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__()`. + `__attrs_pre_init__` also supports being passed the same arguments as `__init__`. - `__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 1c58eaf56..eb7a1fecc 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 @@ def __init__( 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 @@ def add_init(self): 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 @@ def add_attrs_init(self): 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 fmt_setter_with_converter( 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 29673c4af..d0d289d84 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -6,6 +6,7 @@ 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 a00922c2a..556dfd9d7 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -628,6 +628,61 @@ def __attrs_pre_init__(self2): assert 30 == getattr(c, "z", None) + @pytest.mark.parametrize("with_validation", [True, False]) + def test_pre_init_args(self, with_validation, monkeypatch): + """ + Verify that __attrs_pre_init__ gets called with extra args if defined. + """ + monkeypatch.setattr(_config, "_run_validators", with_validation) + + @attr.s + class C: + x = attr.ib() + + def __attrs_pre_init__(self2, x): + self2.z = x + 1 + + c = C(x=10) + + assert 11 == getattr(c, "z", None) + + @pytest.mark.parametrize("with_validation", [True, False]) + def test_pre_init_kwargs(self, with_validation, monkeypatch): + """ + Verify that __attrs_pre_init__ gets called with extra args and kwargs if defined. + """ + monkeypatch.setattr(_config, "_run_validators", with_validation) + + @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 + + c = C(10, y=11) + + assert 22 == getattr(c, "z", None) + + @pytest.mark.parametrize("with_validation", [True, False]) + def test_pre_init_kwargs_only(self, with_validation, monkeypatch): + """ + Verify that __attrs_pre_init__ gets called with extra kwargs only if defined. + """ + monkeypatch.setattr(_config, "_run_validators", with_validation) + + @attr.s + class C: + y = attr.field(kw_only=True) + + def __attrs_pre_init__(self2, y): + self2.z = y + 1 + + c = C(y=11) + + assert 12 == getattr(c, "z", None) + @pytest.mark.parametrize("with_validation", [True, False]) def test_post_init(self, with_validation, monkeypatch): """ From ae776ede80597ab2bb341ee97609471b42febb68 Mon Sep 17 00:00:00 2001 From: Victor Song Date: Fri, 22 Sep 2023 10:38:23 -0500 Subject: [PATCH 2/5] Add changelog entry for `__attrs_pre_init__` args changes --- changelog.d/1187.change.md | 2 ++ 1 file changed, 2 insertions(+) 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 000000000..7f43d81ad --- /dev/null +++ b/changelog.d/1187.change.md @@ -0,0 +1,2 @@ +`__attrs_pre_init__` can now optionally accept the same arguments as `__init__`. +This allows you to, for example, pass arguments to `super().__init__`. From 31a6e8545a391910a37dddcdf9d5bb288334b4a3 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 29 Sep 2023 07:54:11 +0200 Subject: [PATCH 3/5] Don't use monkeypatch in new code --- tests/test_make.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/test_make.py b/tests/test_make.py index 556dfd9d7..ad00bbc52 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -613,27 +613,29 @@ class D: 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, monkeypatch): + def test_pre_init_args(self, with_validation): """ Verify that __attrs_pre_init__ gets called with extra args if defined. """ - monkeypatch.setattr(_config, "_run_validators", with_validation) @attr.s class C: @@ -642,16 +644,19 @@ class C: def __attrs_pre_init__(self2, x): self2.z = x + 1 - c = C(x=10) + 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, monkeypatch): + def test_pre_init_kwargs(self, with_validation): """ Verify that __attrs_pre_init__ gets called with extra args and kwargs if defined. """ - monkeypatch.setattr(_config, "_run_validators", with_validation) @attr.s class C: @@ -661,7 +666,11 @@ class C: def __attrs_pre_init__(self2, x, y): self2.z = x + y + 1 - c = C(10, y=11) + 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) From e78782bf4787b221520570ce3f1f94480c1c55ee Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 29 Sep 2023 08:23:58 +0200 Subject: [PATCH 4/5] Clarify docs --- changelog.d/1187.change.md | 4 ++-- docs/init.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/1187.change.md b/changelog.d/1187.change.md index 7f43d81ad..b06f115aa 100644 --- a/changelog.d/1187.change.md +++ b/changelog.d/1187.change.md @@ -1,2 +1,2 @@ -`__attrs_pre_init__` can now optionally accept the same arguments as `__init__`. -This allows you to, for example, pass arguments to `super().__init__`. +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 fbb20ffbc..3b2e66536 100644 --- a/docs/init.md +++ b/docs/init.md @@ -357,8 +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__()`. - `__attrs_pre_init__` also supports being passed the same arguments as `__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. From efe7bbe39e7c3d685ad7388d50486e464cd7429e Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 29 Sep 2023 08:36:05 +0200 Subject: [PATCH 5/5] Missed one monkeypatching --- tests/test_make.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_make.py b/tests/test_make.py index ad00bbc52..286f18af3 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -675,11 +675,11 @@ def __attrs_pre_init__(self2, x, y): assert 22 == getattr(c, "z", None) @pytest.mark.parametrize("with_validation", [True, False]) - def test_pre_init_kwargs_only(self, with_validation, monkeypatch): + def test_pre_init_kwargs_only(self, with_validation): """ - Verify that __attrs_pre_init__ gets called with extra kwargs only if defined. + Verify that __attrs_pre_init__ gets called with extra kwargs only if + defined. """ - monkeypatch.setattr(_config, "_run_validators", with_validation) @attr.s class C: @@ -688,7 +688,11 @@ class C: def __attrs_pre_init__(self2, y): self2.z = y + 1 - c = C(y=11) + try: + attr.validators.set_disabled(not with_validation) + c = C(y=11) + finally: + attr.validators.set_disabled(False) assert 12 == getattr(c, "z", None)