Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/wrapt/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@
from .__wrapt__ import BaseObjectProxy

# The dictionary registering any post import hooks to be triggered once
# the target module has been imported. Once a module has been imported
# and the hooks fired, the list of hooks recorded against the target
# module will be truncated but the list left in the dictionary. This
# acts as a flag to indicate that the module had already been imported.
# the target module has been imported. Notification removes the module's
# entry before invoking its hooks, so hooks registered during notification
# can run immediately.

_post_import_hooks: dict[str, list[Callable]] = {}
_post_import_hooks_init = False
Expand Down Expand Up @@ -79,6 +78,15 @@ def register_post_import_hook(hook, name):

module = sys.modules.get(name, None)

# A watched module is visible in sys.modules before its body finishes.
# Keep additional hooks in its pending queue until our loader notifies
# them. Once notification starts, the queue has been removed, so hooks
# registered by another hook can still run immediately.
if name in _post_import_hooks and getattr(
getattr(module, "__spec__", None), "_initializing", False
):
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably, just name in _post_import_hooks is enough here, since notify_module_loaded(), as you mentioned in your comment, removes the list of hooks once notification starts (which, by the way, for some reason contradicts the first comment in the module…).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the queue-only condition against a loaded-module case: register a pending hook, then insert an already initialized types.ModuleType into sys.modules, and register another hook. Both the original implementation and this patch run the new hook immediately; checking only queue membership leaves it queued. I retained the initialization guard to preserve that existing behavior and added test_loaded_module_with_pending_hook_runs_new_hook_immediately.

You are right about the introductory comment: I corrected it to describe removing the entry before notification. This follow-up changes only that comment and the regression test. The hook test file passes (12 tests), and the new test also passes with extensions disabled (1 passed, 11 deselected). The latter run printed an ignored pyreadline3 console-destructor warning at interpreter shutdown; no baseline comparison or pre-existing-warning claim is made. The previously recorded broader results are reused because the runtime implementation is unchanged; the full version matrix remains unrun.

This assessment and follow-up used AI assistance; no human review is claimed.

@x42005e1f x42005e1f Oct 30, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both the original implementation and this patch run the new hook immediately; checking only queue membership leaves it queued.

But is this behavior really worth preserving? With it, the old hook is never fired! If the new hook is left in the queue, the firing order will not be violated, and the user is also more likely to notice that something went wrong.

module = None

if module is None:
_post_import_hooks.setdefault(name, []).append(hook)

Expand Down
104 changes: 103 additions & 1 deletion tests/core/test_post_import_hooks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import unittest
import importlib
import pathlib
import sys
import tempfile
import threading
import types
import unittest

import wrapt
from wrapt.importer import _post_import_hooks
Expand Down Expand Up @@ -47,6 +50,57 @@ def hook_this(module):

self.assertEqual(len(invoked), 1)

def test_hook_registered_during_import_waits_for_module_body(self):
name = "_wrapt_partial_import"
registration = name + "_registration"
invoked = []

def first_hook(module):
invoked.append(module.value)
wrapt.register_post_import_hook(
lambda loaded: invoked.append(loaded.value), name
)

with tempfile.TemporaryDirectory() as directory:
pathlib.Path(directory, name + ".py").write_text(
"import " + registration + "\nvalue = 42\n", encoding="utf-8"
)
pathlib.Path(directory, registration + ".py").write_text(
"import wrapt\n"
"@wrapt.when_imported('" + name + "')\n"
"def hook(module):\n"
" module.hook_value = module.value\n",
encoding="utf-8",
)
sys.path.insert(0, directory)
try:
wrapt.register_post_import_hook(first_hook, name)
module = importlib.import_module(name)
self.assertEqual(module.hook_value, 42)
self.assertEqual(invoked, [42, 42])
self.assertNotIn(name, _post_import_hooks)
finally:
sys.path.remove(directory)
sys.path_importer_cache.pop(directory, None)
sys.modules.pop(name, None)
sys.modules.pop(registration, None)
_post_import_hooks.pop(name, None)

def test_loaded_module_with_pending_hook_runs_new_hook_immediately(self):
name = "_wrapt_loaded_module_with_pending_hook"
invoked = []
module = types.ModuleType(name)
try:
wrapt.register_post_import_hook(
lambda loaded: invoked.append("pending"), name
)
sys.modules[name] = module
wrapt.register_post_import_hook(lambda loaded: invoked.append(loaded), name)
self.assertEqual(invoked, [module])
finally:
sys.modules.pop(name, None)
_post_import_hooks.pop(name, None)

def test_before_and_after_import(self):
invoked_one = []
invoked_two = []
Expand All @@ -72,6 +126,54 @@ def hook_this_two(module):
self.assertEqual(len(invoked_one), 1)
self.assertEqual(len(invoked_two), 1)

def test_hook_registered_from_another_thread_waits_for_module_body(self):
name = "_wrapt_threaded_import"
control_name = name + "_control"
control = types.ModuleType(control_name)
control.entered = threading.Event()
control.release = threading.Event()
invoked = []
errors = []

def import_module():
try:
importlib.import_module(name)
except BaseException as exc:
errors.append(exc)

with tempfile.TemporaryDirectory() as directory:
pathlib.Path(directory, name + ".py").write_text(
"import " + control_name + " as control\n"
"control.entered.set()\n"
"assert control.release.wait(5)\n"
"value = 42\n",
encoding="utf-8",
)
sys.path.insert(0, directory)
sys.modules[control_name] = control
thread = threading.Thread(target=import_module)
try:
wrapt.register_post_import_hook(
lambda module: invoked.append(module.value), name
)
thread.start()
self.assertTrue(control.entered.wait(5))
wrapt.register_post_import_hook(
lambda module: invoked.append(module.value), name
)
self.assertEqual(invoked, [])
finally:
control.release.set()
thread.join(timeout=5)
sys.path.remove(directory)
sys.path_importer_cache.pop(directory, None)
sys.modules.pop(name, None)
sys.modules.pop(control_name, None)
_post_import_hooks.pop(name, None)
self.assertFalse(thread.is_alive())
self.assertEqual(errors, [])
self.assertEqual(invoked, [42, 42])

def test_remove_from_sys_modules(self):
invoked = []

Expand Down