Skip to content

Commit a2cb19f

Browse files
committed
refactor: AppmapMiddleware should inherit from ABC
AppmapMiddleware is an abstract class, so it should inherit from abc.ABC. This will take care of ensuring that subclasses implement its abtract methods, removing the need to throw an exception at runtime. In addition, these changes * move Recorder to recorder.py * move Recording to recording.py * move importing code into Importer
1 parent 5858681 commit a2cb19f

20 files changed

+477
-442
lines changed

appmap/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from ._implementation import generation # noqa: F401
33
from ._implementation.env import Env # noqa: F401
44
from ._implementation.labels import labels # noqa: F401
5-
from ._implementation.recording import Recording, instrument_module # noqa: F401
5+
from ._implementation.importer import instrument_module # noqa: F401
6+
from ._implementation.recording import Recording # noqa: F401
67

78
try:
89
from . import flask # noqa: F401

appmap/_implementation/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
from . import configuration
22
from . import env as appmapenv
3-
from . import event, metadata, recording
3+
from . import event, importer, metadata, recorder
44
from .py_version_check import check_py_version
55

66

77
def initialize(**kwargs):
88
check_py_version()
99
appmapenv.initialize(**kwargs)
1010
event.initialize()
11-
recording.initialize()
12-
configuration.initialize() # needs to be initialized after recording
11+
importer.initialize()
12+
recorder.initialize()
13+
configuration.initialize() # needs to be initialized after recorder
1314
metadata.initialize()
1415

1516

appmap/_implementation/configuration.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,8 @@
1818
from ..labeling import presets as label_presets
1919
from . import utils
2020
from .env import Env
21+
from .importer import Filter, Importer
2122
from .instrument import instrument
22-
from .labels import LabelSet
23-
from .metadata import Metadata
24-
from .recording import Filter, FilterableCls, Recorder
2523

2624
logger = logging.getLogger(__name__)
2725

@@ -366,8 +364,8 @@ def __init__(self, *args, **kwargs):
366364

367365
def initialize():
368366
Config().initialize()
369-
Recorder.use_filter(BuiltinFilter)
370-
Recorder.use_filter(ConfigFilter)
367+
Importer.use_filter(BuiltinFilter)
368+
Importer.use_filter(ConfigFilter)
371369

372370

373371
initialize()

appmap/_implementation/importer.py

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import functools
2+
import inspect
3+
import logging
4+
import sys
5+
import types
6+
from abc import ABC, abstractmethod
7+
from collections import namedtuple
8+
from collections.abc import MutableSequence
9+
10+
import appmap.wrapt as wrapt
11+
12+
from .env import Env
13+
from .utils import FnType
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
Filterable = namedtuple("Filterable", "fqname obj")
19+
20+
21+
class FilterableMod(Filterable):
22+
__slots__ = ()
23+
24+
def __new__(c, mod):
25+
fqname = mod.__name__
26+
return super(FilterableMod, c).__new__(c, fqname, mod)
27+
28+
def classify_fn(self, _):
29+
return FnType.MODULE
30+
31+
32+
class FilterableCls(Filterable):
33+
__slots__ = ()
34+
35+
def __new__(c, cls):
36+
fqname = "%s.%s" % (cls.__module__, cls.__qualname__)
37+
return super(FilterableCls, c).__new__(c, fqname, cls)
38+
39+
def classify_fn(self, static_fn):
40+
return FnType.classify(static_fn)
41+
42+
43+
class FilterableFn(
44+
namedtuple(
45+
"FilterableFn",
46+
Filterable._fields
47+
+ (
48+
"scope",
49+
"static_fn",
50+
),
51+
)
52+
):
53+
__slots__ = ()
54+
55+
def __new__(c, scope, fn, static_fn):
56+
fqname = "%s.%s" % (scope.fqname, fn.__name__)
57+
self = super(FilterableFn, c).__new__(c, fqname, fn, scope, static_fn)
58+
return self
59+
60+
@property
61+
def fntype(self):
62+
return self.scope.classify_fn(self.static_fn)
63+
64+
65+
class Filter(ABC):
66+
def __init__(self, next_filter):
67+
self.next_filter = next_filter
68+
69+
@abstractmethod
70+
def filter(self, filterable):
71+
"""
72+
Determine whether the given class should have its methods
73+
instrumented. Return True if it should be, False if it should
74+
not be, or call the next filter if this filter can't decide.
75+
"""
76+
77+
@abstractmethod
78+
def wrap(self, filterable):
79+
"""
80+
Determine whether the given filterable function should be
81+
instrumented. If so, return a new function that wraps the
82+
old. If not, return the original function.
83+
"""
84+
85+
86+
class NullFilter(Filter):
87+
def __init__(self, next_filter=None):
88+
super().__init__(next_filter)
89+
90+
def filter(self, filterable):
91+
return False
92+
93+
def wrap(self, filterable):
94+
return filterable.obj
95+
96+
97+
def is_class(c):
98+
# We don't want to use inspect.isclass here. It uses isinstance to
99+
# check the class of the object, which will invoke any method
100+
# bound to __class__. (For example, celery.local.Proxy uses this
101+
# mechanism to return the class of the proxied object.)
102+
#
103+
return inspect._is_type(c) # pylint: disable=protected-access
104+
105+
106+
def get_classes(module):
107+
return [v for __, v in module.__dict__.items() if is_class(v)]
108+
109+
110+
def get_members(cls):
111+
"""
112+
Get the function members of the given class. Unlike
113+
inspect.getmembers, this function only calls getattr on functions,
114+
to avoid potential side effects.
115+
116+
Returns a list of tuples of the form (key, dict_value, attr_value):
117+
* key is the attribute name
118+
* dict_value is cls.__dict__[key]
119+
* and attr_value is getattr(cls, key)
120+
"""
121+
122+
def is_member_func(m):
123+
t = type(m)
124+
if (
125+
t == types.BuiltinFunctionType or t == types.BuiltinMethodType # noqa: E721
126+
): # noqa: E129
127+
return False
128+
129+
return (
130+
t == types.FunctionType # noqa: E721
131+
or t == types.MethodType
132+
or FnType.classify(m) in FnType.STATIC | FnType.CLASS
133+
)
134+
135+
# Note that we only want to instrument the functions that are
136+
# defined within the class itself, i.e. those which get added to
137+
# the class' __dict__ when the class is created. If we were to
138+
# instead iterate over dir(cls), we would see functions from
139+
# superclasses, too. Those don't need to be instrumented here,
140+
# they'll get taken care of when the superclass is imported.
141+
ret = []
142+
modname = cls.__module__ if hasattr(cls, "__module__") else cls.__name__
143+
for key in cls.__dict__:
144+
if key.startswith("__"):
145+
continue
146+
static_value = inspect.getattr_static(cls, key)
147+
if not is_member_func(static_value):
148+
continue
149+
value = getattr(cls, key)
150+
if value.__module__ != modname:
151+
continue
152+
ret.append((key, static_value, value))
153+
return ret
154+
155+
156+
class Importer:
157+
filter_stack = [NullFilter]
158+
filter_chain = []
159+
160+
def get_filter_stack(self):
161+
return self.filter_stack
162+
163+
@classmethod
164+
def initialize(cls):
165+
cls.filter_stack = [NullFilter]
166+
cls.filter_chain = []
167+
168+
@classmethod
169+
def use_filter(cls, filter_class):
170+
cls.filter_stack.append(filter_class)
171+
172+
@classmethod
173+
def do_import(cls, *args, **kwargs):
174+
mod = args[0]
175+
if mod.__name__.startswith("appmap"):
176+
return
177+
178+
logger.debug("do_import, mod %s args %s kwargs %s", mod, args, kwargs)
179+
if not cls.filter_chain:
180+
logger.debug(" filter_stack %s", cls.filter_stack)
181+
cls.filter_chain = cls.filter_stack[0](None)
182+
for filter_ in cls.filter_stack[1:]:
183+
cls.filter_chain = filter_(cls.filter_chain)
184+
logger.debug(" self.filter chain: %s", cls.filter_chain)
185+
186+
def instrument_functions(filterable):
187+
if not cls.filter_chain.filter(filterable):
188+
return
189+
190+
logger.info(" looking for members of %s", filterable.obj)
191+
functions = get_members(filterable.obj)
192+
logger.debug(" functions %s", functions)
193+
194+
for fn_name, static_fn, fn in functions:
195+
new_fn = cls.filter_chain.wrap(FilterableFn(filterable, fn, static_fn))
196+
if fn != new_fn:
197+
wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn)
198+
199+
instrument_functions(FilterableMod(mod))
200+
classes = get_classes(mod)
201+
logger.debug(" classes %s", classes)
202+
for c in classes:
203+
instrument_functions(FilterableCls(c))
204+
205+
206+
def wrap_finder_function(fn, decorator):
207+
ret = fn
208+
fn_name = fn.func.__name__ if isinstance(fn, functools.partial) else fn.__name__
209+
marker = "_appmap_wrapped_%s" % fn_name
210+
211+
# Figure out which object should get the marker attribute. If fn
212+
# is a bound function, put it on the object it's bound to,
213+
# otherwise put it on the function itself.
214+
obj = fn.__self__ if hasattr(fn, "__self__") else fn
215+
216+
if getattr(obj, marker, None) is None:
217+
logger.debug("wrapping %r", fn)
218+
ret = decorator(fn)
219+
setattr(obj, marker, True)
220+
else:
221+
logger.debug("already wrapped, %r", fn)
222+
223+
return ret
224+
225+
226+
@wrapt.decorator
227+
def wrapped_exec_module(exec_module, _, args, kwargs):
228+
logger.debug("exec_module %r args %s kwargs %s", exec_module, args, kwargs)
229+
exec_module(*args, **kwargs)
230+
# Only process imports if we're currently enabled. This
231+
# handles the case where we previously hooked the finders, but
232+
# were subsequently disabled (e.g. during testing).
233+
if Env.current.enabled:
234+
Importer.do_import(*args, **kwargs)
235+
236+
237+
def wrap_exec_module(exec_module):
238+
return wrap_finder_function(exec_module, wrapped_exec_module)
239+
240+
241+
@wrapt.decorator
242+
def wrapped_find_spec(find_spec, _, args, kwargs):
243+
spec = find_spec(*args, **kwargs)
244+
if spec is not None:
245+
if getattr(spec.loader, "exec_module", None) is not None:
246+
loader = spec.loader
247+
# This is kind of gross. As the comment linked to below describes, wrapt has trouble
248+
# identifying methods decorated with @staticmethod. It offers two suggested fixes:
249+
# update the class definition, or patch the function found in __dict__. We can't do the
250+
# former, so do the latter instead.
251+
# https://github.com/GrahamDumpleton/wrapt/blob/68316bea668fd905a4acb21f37f12596d8c30d80/src/wrapt/wrappers.py#L691
252+
#
253+
# TODO: determine if we can use wrapt.wrap_function_wrapper to simplify this code
254+
exec_module = inspect.getattr_static(loader, "exec_module")
255+
if isinstance(exec_module, staticmethod):
256+
loader.exec_module = wrap_exec_module(exec_module)
257+
else:
258+
loader.exec_module = wrap_exec_module(loader.exec_module)
259+
else:
260+
logger.debug("no exec_module for loader %r", spec.loader)
261+
return spec
262+
263+
264+
def wrap_finder_find_spec(finder):
265+
find_spec = getattr(finder, "find_spec", None)
266+
if find_spec is None:
267+
logger.debug("no find_spec for finder %r", finder)
268+
return
269+
270+
finder.find_spec = wrap_finder_function(find_spec, wrapped_find_spec)
271+
272+
273+
class MetapathObserver(MutableSequence):
274+
def __init__(self, meta_path):
275+
self._meta_path = meta_path
276+
277+
def __getitem__(self, key):
278+
return self._meta_path.__getitem__(key)
279+
280+
def __setitem__(self, key, value):
281+
self._meta_path.__setitem__(key, value)
282+
283+
def __delitem__(self, key):
284+
self._meta_path.__delitem__(key)
285+
286+
def __len__(self):
287+
return self._meta_path.__len__()
288+
289+
def insert(self, index, value):
290+
wrap_finder_find_spec(value)
291+
self._meta_path.insert(index, value)
292+
293+
def copy(self):
294+
return self._meta_path.copy()
295+
296+
297+
def initialize():
298+
Importer.initialize()
299+
# If we're not enabled, there's no reason to hook the finders.
300+
if Env.current.enabled:
301+
logger.debug("sys.metapath: %s", sys.meta_path)
302+
for finder in sys.meta_path:
303+
wrap_finder_find_spec(finder)
304+
305+
# Make sure we instrument any finders that get added in the
306+
# future.
307+
sys.meta_path = MetapathObserver(sys.meta_path.copy())
308+
309+
310+
def instrument_module(module):
311+
"""
312+
Force (re-)instrumentation of a module.
313+
This can be useful if a module was already loaded before appmap hooks
314+
were set up or configured to instrument that module.
315+
"""
316+
Importer.do_import(module)

appmap/_implementation/instrument.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from . import event
88
from .event import CallEvent
9-
from .recording import Recorder
9+
from .recorder import Recorder
1010
from .utils import appmap_tls
1111

1212
logger = logging.getLogger(__name__)

appmap/_implementation/labels.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from collections import defaultdict
22
from typing import Dict, List, Optional, Union
33

4-
from .recording import Filterable
4+
from .importer import Filterable
55

66

77
class labels:

0 commit comments

Comments
 (0)