From 79712b55d27a725e0923fa1a81698f92c87e0f13 Mon Sep 17 00:00:00 2001 From: rajnish-verma-22172 Date: Wed, 21 Oct 2026 11:39:30 +0530 Subject: [PATCH] Copy historic hook call arguments Snapshot the keyword argument mapping when recording a historic call so mutations by the caller do not alter replay for late plugins. Add regression coverage and document the behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- changelog/808.bugfix.rst | 1 + docs/index.rst | 4 ++++ src/pluggy/_caller.py | 2 +- testing/test_invocations.py | 23 +++++++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 changelog/808.bugfix.rst diff --git a/changelog/808.bugfix.rst b/changelog/808.bugfix.rst new file mode 100644 index 00000000..9a80ec2a --- /dev/null +++ b/changelog/808.bugfix.rst @@ -0,0 +1 @@ +Historic hook calls now copy their keyword argument mapping, so later changes to the caller's mapping do not affect calls replayed to newly registered plugins. diff --git a/docs/index.rst b/docs/index.rst index f2aaf957..9eed1bc8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -995,6 +995,10 @@ using the :py:meth:`~pluggy.HookCaller.call_historic()` method: # historic callback is invoked here pm.register(mylateplugin) +The keyword argument mapping is copied when the call is recorded, so later +changes to the caller's mapping do not affect calls replayed to newly registered +plugins. + Note that if you :py:meth:`~pluggy.HookCaller.call_historic()` the :py:class:`~pluggy.HookCaller` (and thus your calling code) can not receive results back from the underlying *hookimpl* functions. diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 52b02316..0b0013c7 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -215,7 +215,7 @@ def call_historic( kwargs = kwargs or {} kwargs = self._apply_defaults(kwargs) self._verify_all_args_are_provided(kwargs) - self._call_history.append((kwargs, result_callback)) + self._call_history.append((dict(kwargs), result_callback)) # Historizing hooks don't return results. # Remember firstresult isn't compatible with historic. # Copy because plugins may register other plugins during iteration (#438). diff --git a/testing/test_invocations.py b/testing/test_invocations.py index ccae8aea..18e509df 100644 --- a/testing/test_invocations.py +++ b/testing/test_invocations.py @@ -597,6 +597,29 @@ def hello(self, arg, new_arg): assert seen == [(1, "spec"), (2, "call")] + def test_historic_replay_copies_call_kwargs(self, pm: PluginManager) -> None: + class Api: + @hookspec(historic=True) + def hello(self, arg): + pass + + pm.add_hookspecs(Api) + + kwargs = {"arg": "at-call-time"} + pm.hook.hello.call_historic(kwargs=kwargs) + kwargs["arg"] = "mutated" + + seen = [] + + class Plugin: + @hookimpl + def hello(self, arg): + seen.append(arg) + + pm.register(Plugin()) + + assert seen == ["at-call-time"] + def test_call_extra(self, pm: PluginManager) -> None: """Test calls with extras respect defaults."""