From 64c8a2086b1c9ed800e163b3d7f866a98d05082d Mon Sep 17 00:00:00 2001 From: simpleqt <2015900818@qq.com> Date: Wed, 21 Oct 2026 00:25:49 +0800 Subject: [PATCH 1/2] Freeze the mapping a historic call is memorized with `HookCaller.call_historic()` keeps the ``kwargs`` mapping in ``_call_history`` and replays it for every plugin registered later. When the hookspec declares no argument defaults, that mapping is the caller's own object (``_apply_defaults()`` returns it unchanged), so mutating it after the call silently rewrites the memorized call: plugins registered afterwards, and the ``result_callback``, are handed values that were never passed to the hook call. With a hookspec default the mapping is copied already, so the two paths disagree. Copy the mapping instead, so that a historic call is replayed with the values it was made with. --- src/pluggy/_caller.py | 4 ++++ testing/test_pluginmanager.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 52b02316..e31d8928 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -214,6 +214,10 @@ def call_historic( assert self._call_history is not None kwargs = kwargs or {} kwargs = self._apply_defaults(kwargs) + # ``kwargs`` may be a mapping the caller keeps and mutates; the call is + # memorized to be replayed, so it has to keep the values it was made + # with. + kwargs = dict(kwargs) self._verify_all_args_are_provided(kwargs) self._call_history.append((kwargs, result_callback)) # Historizing hooks don't return results. diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 7de6c6de..1e293e7a 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -540,6 +540,37 @@ def he_method1(self, arg): assert out == [1, 10, 120, 12] +def test_call_historic_keeps_the_values_the_call_was_made_with( + pm: PluginManager, +) -> None: + """The memorized call is replayed with the values it was made with, even + when the caller's mapping is mutated after the call.""" + + class Hooks: + @hookspec(historic=True) + def he_method1(self, arg): ... + + pm.add_hookspecs(Hooks) + + out = [] + kwargs = {"arg": "value-at-call-time"} + pm.hook.he_method1.call_historic(lambda res: out.append(res), kwargs=kwargs) + kwargs["arg"] = "mutated-after-the-call" + + seen = [] + + class Plugin: + @hookimpl + def he_method1(self, arg): + seen.append(arg) + return arg + + pm.register(Plugin()) + + assert seen == ["value-at-call-time"] + assert out == ["value-at-call-time"] + + def test_historic_with_subset_hook_caller(pm: PluginManager) -> None: class Hooks: @hookspec(historic=True) From bdbff4ed56b2e3a967b305a6f2a6b9bdbff118f0 Mon Sep 17 00:00:00 2001 From: simpleqt <2015900818@qq.com> Date: Wed, 21 Oct 2026 00:32:49 +0800 Subject: [PATCH 2/2] Add the changelog fragment for #806 --- changelog/806.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/806.bugfix.rst diff --git a/changelog/806.bugfix.rst b/changelog/806.bugfix.rst new file mode 100644 index 00000000..b23affe0 --- /dev/null +++ b/changelog/806.bugfix.rst @@ -0,0 +1 @@ +A historic hook call now remembers the arguments it was made with instead of the mapping the caller passed in, so mutating that mapping afterwards no longer changes what plugins registered later, or the result callback, receive (`#806 `_).