Skip to content

Commit d2f5644

Browse files
Kyle-Verhoogbrettlangdon
authored andcommitted
[core] Add import hook module (#769)
* [core] Add hook module with tests * [core] linting * [core] add more tests * [core] linting * fix bad merge * [core] address comments - fix test - add dedup check and log * [core] improve documentation for matcher * [core] use module_name helper * [core] deregister takes hook directly * [core] enhance docstrings Co-Authored-By: Kyle-Verhoog <[email protected]> * [core] docstring improvement Co-Authored-By: Kyle-Verhoog <[email protected]> * [core] deregister returns outcome; docstring updates * [core] remove stale comment
1 parent ef240b0 commit d2f5644

File tree

6 files changed

+404
-2
lines changed

6 files changed

+404
-2
lines changed

ddtrace/compat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
PYTHON_VERSION_INFO = sys.version_info
2020
PY2 = sys.version_info[0] == 2
21+
PY3 = sys.version_info[0] == 3
2122

2223
# Infos about python passed to the trace agent through the header
2324
PYTHON_VERSION = platform.python_version()
@@ -34,6 +35,7 @@
3435
Queue = six.moves.queue.Queue
3536
iteritems = six.iteritems
3637
reraise = six.reraise
38+
reload_module = six.moves.reload_module
3739

3840
stringify = six.text_type
3941
string_type = six.string_types[0]

ddtrace/utils/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,12 @@ def merge_dicts(x, y):
44
z = x.copy() # start with x's keys and values
55
z.update(y) # modifies z with y's keys and values & returns None
66
return z
7+
8+
9+
def get_module_name(module):
10+
"""Returns a module's name or None if one cannot be found.
11+
Relevant PEP: https://www.python.org/dev/peps/pep-0451/
12+
"""
13+
if hasattr(module, '__spec__'):
14+
return module.__spec__.name
15+
return getattr(module, '__name__', None)

ddtrace/utils/hook.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""
2+
This module is based off of wrapt.importer (wrapt==1.11.0)
3+
https://github.com/GrahamDumpleton/wrapt/blob/4bcd190457c89e993ffcfec6dad9e9969c033e9e/src/wrapt/importer.py#L127-L136
4+
5+
The reasoning for this is that wrapt.importer does not provide a mechanism to
6+
remove the import hooks and that wrapt removes the hooks after they are fired.
7+
8+
So this module differs from wrapt.importer in that:
9+
- removes unnecessary functionality (like allowing hooks to be import paths)
10+
- deregister_post_import_hook is introduced to remove hooks
11+
- the values of _post_import_hooks can only be lists (instead of allowing None)
12+
- notify_module_loaded is modified to not remove the hooks when they are
13+
fired.
14+
"""
15+
import logging
16+
import sys
17+
import threading
18+
19+
from wrapt.decorators import synchronized
20+
21+
from ddtrace.compat import PY3
22+
from ddtrace.utils import get_module_name
23+
24+
25+
log = logging.getLogger(__name__)
26+
27+
28+
_post_import_hooks = {}
29+
_post_import_hooks_init = False
30+
_post_import_hooks_lock = threading.RLock()
31+
32+
33+
@synchronized(_post_import_hooks_lock)
34+
def register_post_import_hook(name, hook):
35+
"""
36+
Registers a module import hook, ``hook`` for a module with name ``name``.
37+
38+
If the module is already imported the hook is called immediately and a
39+
debug message is logged since this should not be expected in our use-case.
40+
41+
:param name: Name of the module (full dotted path)
42+
:type name: str
43+
:param hook: Callable to be invoked with the module when it is imported.
44+
:type hook: Callable
45+
:return:
46+
"""
47+
# Automatically install the import hook finder if it has not already
48+
# been installed.
49+
global _post_import_hooks_init
50+
51+
if not _post_import_hooks_init:
52+
_post_import_hooks_init = True
53+
sys.meta_path.insert(0, ImportHookFinder())
54+
55+
hooks = _post_import_hooks.get(name, [])
56+
57+
if hook in hooks:
58+
log.debug('hook "{}" already exists on module "{}"'.format(hook, name))
59+
return
60+
61+
module = sys.modules.get(name, None)
62+
63+
# If the module has been imported already fire the hook and log a debug msg.
64+
if module:
65+
log.debug('module "{}" already imported, firing hook'.format(name))
66+
hook(module)
67+
68+
hooks.append(hook)
69+
_post_import_hooks[name] = hooks
70+
71+
72+
@synchronized(_post_import_hooks_lock)
73+
def notify_module_loaded(module):
74+
"""
75+
Indicate that a module has been loaded. Any post import hooks which were
76+
registered for the target module will be invoked.
77+
78+
Any raised exceptions will be caught and an error message indicating that
79+
the hook failed.
80+
81+
:param module: The module being loaded
82+
:type module: ``types.ModuleType``
83+
"""
84+
name = get_module_name(module)
85+
hooks = _post_import_hooks.get(name, [])
86+
87+
for hook in hooks:
88+
try:
89+
hook(module)
90+
except Exception as err:
91+
log.warn('hook "{}" for module "{}" failed: {}'.format(hook, name, err))
92+
93+
94+
class _ImportHookLoader(object):
95+
"""
96+
A custom module import finder. This intercepts attempts to import
97+
modules and watches out for attempts to import target modules of
98+
interest. When a module of interest is imported, then any post import
99+
hooks which are registered will be invoked.
100+
"""
101+
def load_module(self, fullname):
102+
module = sys.modules[fullname]
103+
notify_module_loaded(module)
104+
return module
105+
106+
107+
class _ImportHookChainedLoader(object):
108+
def __init__(self, loader):
109+
self.loader = loader
110+
111+
def load_module(self, fullname):
112+
module = self.loader.load_module(fullname)
113+
notify_module_loaded(module)
114+
return module
115+
116+
117+
class ImportHookFinder:
118+
def __init__(self):
119+
self.in_progress = {}
120+
121+
@synchronized(_post_import_hooks_lock)
122+
def find_module(self, fullname, path=None):
123+
# If the module being imported is not one we have registered
124+
# post import hooks for, we can return immediately. We will
125+
# take no further part in the importing of this module.
126+
127+
if fullname not in _post_import_hooks:
128+
return None
129+
130+
# When we are interested in a specific module, we will call back
131+
# into the import system a second time to defer to the import
132+
# finder that is supposed to handle the importing of the module.
133+
# We set an in progress flag for the target module so that on
134+
# the second time through we don't trigger another call back
135+
# into the import system and cause a infinite loop.
136+
137+
if fullname in self.in_progress:
138+
return None
139+
140+
self.in_progress[fullname] = True
141+
142+
# Now call back into the import system again.
143+
144+
try:
145+
if PY3:
146+
# For Python 3 we need to use find_spec().loader
147+
# from the importlib.util module. It doesn't actually
148+
# import the target module and only finds the
149+
# loader. If a loader is found, we need to return
150+
# our own loader which will then in turn call the
151+
# real loader to import the module and invoke the
152+
# post import hooks.
153+
try:
154+
import importlib.util
155+
loader = importlib.util.find_spec(fullname).loader
156+
except (ImportError, AttributeError):
157+
loader = importlib.find_loader(fullname, path)
158+
if loader:
159+
return _ImportHookChainedLoader(loader)
160+
else:
161+
# For Python 2 we don't have much choice but to
162+
# call back in to __import__(). This will
163+
# actually cause the module to be imported. If no
164+
# module could be found then ImportError will be
165+
# raised. Otherwise we return a loader which
166+
# returns the already loaded module and invokes
167+
# the post import hooks.
168+
__import__(fullname)
169+
return _ImportHookLoader()
170+
171+
finally:
172+
del self.in_progress[fullname]
173+
174+
175+
@synchronized(_post_import_hooks_lock)
176+
def deregister_post_import_hook(modulename, hook):
177+
"""
178+
Deregisters post import hooks for a module given the module name and a hook
179+
that was previously installed.
180+
181+
:param modulename: Name of the module the hook is installed on.
182+
:type: str
183+
:param hook: The hook to remove (the function itself)
184+
:type hook: Callable
185+
:return: whether a hook was removed or not
186+
"""
187+
if modulename not in _post_import_hooks:
188+
return False
189+
190+
hooks = _post_import_hooks[modulename]
191+
192+
try:
193+
hooks.remove(hook)
194+
return True
195+
except ValueError:
196+
return False

tests/subprocesstest.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,19 @@ def _run_test_in_subprocess(self, result):
8484
stderr=subprocess.PIPE,
8585
env=sp_test_env,
8686
)
87-
_, stderr = sp.communicate()
87+
stdout, stderr = sp.communicate()
8888

8989
if sp.returncode:
9090
try:
9191
cmdf = ' '.join(sp_test_cmd)
9292
raise Exception('Subprocess Test "{}" Failed'.format(cmdf))
9393
except Exception:
9494
exc_info = sys.exc_info()
95-
sys.stderr.write(stderr)
95+
96+
# DEV: stderr, stdout are byte sequences so to print them nicely
97+
# back out they should be decoded.
98+
sys.stderr.write(stderr.decode())
99+
sys.stdout.write(stdout.decode())
96100
result.addFailure(self, exc_info)
97101
else:
98102
result.addSuccess(self)

0 commit comments

Comments
 (0)