Skip to content

Commit 7b3119a

Browse files
committed
fix: reenable instrumentation of properties
Have APPMAP_INSTRUMENT_PROPERTIES default to true once again. Special-case properties that have operator.attrgetter as their getter. Django uses this pattern extensively, and this special-case fixes some failures when trying to generate AppMap data for Django tests.
1 parent f092a77 commit 7b3119a

File tree

11 files changed

+154
-119
lines changed

11 files changed

+154
-119
lines changed

_appmap/event.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring
22
import inspect
33
import logging
4+
import operator
45
import threading
56
from functools import lru_cache, partial
67
from inspect import Parameter, Signature
@@ -180,18 +181,19 @@ class CallEvent(Event):
180181
__slots__ = ["_fn", "_fqfn", "static", "receiver", "parameters", "labels", "auxtype"]
181182

182183
@staticmethod
183-
def make(fn, fntype):
184+
def make(filterable):
184185
"""
185186
Return a factory for creating new CallEvents based on
186187
introspecting the given function.
187188
"""
188189

189190
# Delete the labels so the app doesn't see them.
191+
fn = filterable.obj
190192
labels = getattr(fn, "_appmap_labels", None)
191193
if labels:
192194
del fn._appmap_labels
193195

194-
return partial(CallEvent, fn, fntype, labels=labels)
196+
return partial(CallEvent, filterable, labels=labels)
195197

196198
@staticmethod
197199
def make_params(filterable):
@@ -312,10 +314,28 @@ def comment(self):
312314
comment = inspect.getcomments(self._fn)
313315
return comment
314316

315-
def __init__(self, fn, fntype, parameters, labels):
317+
def __init__(self, filterable, parameters, labels):
316318
super().__init__("call")
319+
fn = filterable.obj
317320
self._fn = fn
318-
self._fqfn = FqFnName(fn)
321+
if type(fn) is not operator.attrgetter:
322+
# fn is a regular function
323+
modname = fn.__module__
324+
qualname = fn.__qualname__
325+
elif (parts := filterable.fqname.split(".")) and len(parts) > 2:
326+
# fn is an attrgetter, which will is being used as the getter for a property. If
327+
# filterable.fqname has enough components to be the fully-qualified name of a class
328+
# member, set the module name and qualname based on those components.
329+
modname = ".".join(parts[:-2])
330+
qualname = ".".join(parts[-2:])
331+
else:
332+
# The two previous cases should handle all known possibilities, but don't crash if
333+
# somethign else sneaks in.
334+
modname = "unknown"
335+
qualname = "unknown"
336+
self._fqfn = FqFnName(modname, qualname)
337+
338+
fntype = filterable.fntype
319339
self.static = fntype in FnType.STATIC | FnType.CLASS | FnType.MODULE
320340
self.receiver = None
321341
if fntype in FnType.CLASS | FnType.INSTANCE:

_appmap/importer.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def is_member_func(m):
134134
static_value = inspect.getattr_static(cls, key)
135135
# Don't use isinstance to check the type of static_value -- we don't want to invoke the
136136
# descriptor protocol.
137-
if Importer.instrument_properties and type(static_value) is property: # pylint: disable=unidiomatic-typecheck
137+
if Importer.instrument_properties and type(static_value) is property:
138138
properties[key] = (
139139
static_value,
140140
{
@@ -167,7 +167,7 @@ def initialize(cls):
167167
cls.filter_chain = []
168168
cls._skip_instrumenting = ("appmap", "_appmap")
169169
cls.instrument_properties = (
170-
Env.current.get("APPMAP_INSTRUMENT_PROPERTIES", "false").lower() == "true"
170+
Env.current.get("APPMAP_INSTRUMENT_PROPERTIES", "true").lower() == "true"
171171
)
172172

173173
@classmethod
@@ -221,7 +221,8 @@ def instrument_functions(filterable, selected_functions=None):
221221
new_fn = cls.instrument_function(prop_name, filterableFn, selected_functions)
222222
if new_fn != fn:
223223
new_fn = wrapt.FunctionWrapper(fn, new_fn)
224-
# Set _appmap_instrumented on the FunctionWrapper, not on the wrapped function
224+
# Set _appmap_instrumented on the FunctionWrapper, not on the wrapped
225+
# function.
225226
new_fn._appmap_instrumented = True # pylint: disable=protected-access
226227

227228
instrumented_fns[k] = new_fn

_appmap/instrument.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def instrument(filterable):
121121
logger.debug("hooking %s", filterable.fqname)
122122
fn = filterable.obj
123123

124-
make_call_event = event.CallEvent.make(fn, filterable.fntype)
124+
make_call_event = event.CallEvent.make(filterable)
125125
params = CallEvent.make_params(filterable)
126126

127127
# django depends on being able to find the cache_clear attribute

_appmap/test/data/appmap.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ packages:
44
- path: example_class.Super
55
shallow: true
66
- path: example_class
7+
- path: properties_class
78
- path: appmap_testing
89
- path: package1
910
- dist: PyYAML

_appmap/test/data/example_class.py

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -114,55 +114,6 @@ def with_comment(self):
114114
def return_self(self):
115115
return self
116116

117-
def __init__(self):
118-
self._read_only = "read only"
119-
self._fully_accessible = "fully accessible"
120-
self._undecorated = "undecorated"
121-
122-
@property
123-
def read_only(self):
124-
"""Read-only"""
125-
return self._read_only
126-
127-
@property
128-
def fully_accessible(self):
129-
"""Fully-accessible"""
130-
return self._fully_accessible
131-
132-
@fully_accessible.setter
133-
def fully_accessible(self, v):
134-
self._fully_accessible = v
135-
136-
@fully_accessible.deleter
137-
def fully_accessible(self):
138-
del self._fully_accessible
139-
140-
def get_undecorated(self):
141-
return self._undecorated
142-
143-
def set_undecorated(self, value):
144-
self._undecorated = value
145-
146-
def delete_undecorated(self):
147-
del self._undecorated
148-
149-
undecorated_property = property(get_undecorated, set_undecorated, delete_undecorated)
150-
151-
def set_write_only(self, v):
152-
self._write_only = v
153-
154-
def del_write_only(self):
155-
del self._write_only
156-
157-
write_only = property(None, set_write_only, del_write_only, "Write-only")
158-
159-
def raise_base_exception(self) -> NoReturn:
160-
raise BaseException("not derived from Exception") # pylint: disable=broad-exception-raised
161117

162118
def modfunc():
163119
return "Hello world!"
164-
165-
if __name__ == "__main__":
166-
ec = ExampleClass()
167-
ec.fully_accessible = "updated"
168-
print(ec.fully_accessible)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from functools import cached_property
2+
import operator
3+
from typing import NoReturn
4+
5+
6+
class PropertiesClass:
7+
def __init__(self):
8+
self._read_only = "read only"
9+
self._fully_accessible = "fully accessible"
10+
self._undecorated = "undecorated"
11+
12+
@property
13+
def read_only(self):
14+
"""Read-only"""
15+
return self._read_only
16+
17+
@property
18+
def fully_accessible(self):
19+
"""Fully-accessible"""
20+
return self._fully_accessible
21+
22+
@fully_accessible.setter
23+
def fully_accessible(self, v):
24+
self._fully_accessible = v
25+
26+
@fully_accessible.deleter
27+
def fully_accessible(self):
28+
del self._fully_accessible
29+
30+
def get_undecorated(self):
31+
return self._undecorated
32+
33+
def set_undecorated(self, value):
34+
self._undecorated = value
35+
36+
def delete_undecorated(self):
37+
del self._undecorated
38+
39+
undecorated_property = property(get_undecorated, set_undecorated, delete_undecorated)
40+
41+
def set_write_only(self, v):
42+
self._write_only = v
43+
44+
def del_write_only(self):
45+
del self._write_only
46+
47+
write_only = property(None, set_write_only, del_write_only, "Write-only")
48+
49+
def raise_base_exception(self) -> NoReturn:
50+
raise BaseException("not derived from Exception") # pylint: disable=broad-exception-raised
51+
52+
@cached_property
53+
def cached_read_only(self):
54+
return self._read_only
55+
56+
operator_read_only = property(operator.attrgetter("cached_read_only"))

_appmap/test/test_params.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ def __init__(self, C):
2828

2929
@classmethod
3030
def prepare(cls, ffn):
31-
fn = ffn.obj
32-
make_call_event = CallEvent.make(fn, ffn.fntype)
31+
make_call_event = CallEvent.make(ffn)
3332
params = CallEvent.make_params(ffn)
3433

3534
def wrapped_fn(_, instance, args, kwargs):

_appmap/test/test_properties.py

Lines changed: 60 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,47 +5,43 @@
55
import pytest
66
from _appmap.test.helpers import DictIncluding
77

8-
pytestmark = pytest.mark.skip(reason="property instrumentation is broken in Django")
8+
pytestmark = pytest.mark.appmap_enabled
99

1010
@pytest.fixture(autouse=True)
1111
def setup(with_data_dir): # pylint: disable=unused-argument
12-
# with_data_dir sets up sys.path so example_class can be imported
12+
# with_data_dir sets up sys.path so properties_class can be imported
1313
pass
1414

1515

1616
def test_getter_instrumented(events):
17-
from example_class import ExampleClass
17+
from properties_class import PropertiesClass
1818

19-
ec = ExampleClass()
19+
ec = PropertiesClass()
2020

21-
actual = ExampleClass.read_only.__doc__
21+
actual = PropertiesClass.read_only.__doc__
2222
assert actual == "Read-only"
2323

2424
assert ec.read_only == "read only"
2525

2626
with pytest.raises(AttributeError, match=r".*(has no setter|can't set attribute).*"):
27-
# E AttributeError: can't set attribute
28-
2927
ec.read_only = "not allowed"
3028

3129
with pytest.raises(AttributeError, match=r".*(has no deleter|can't delete attribute).*"):
3230
del ec.read_only
3331

3432
assert len(events) == 2
35-
assert events[0].to_dict() == DictIncluding(
36-
{
37-
"event": "call",
38-
"defined_class": "example_class.ExampleClass",
39-
"method_id": "read_only (get)",
40-
}
41-
)
33+
assert events[0].to_dict() == DictIncluding({
34+
"event": "call",
35+
"defined_class": "properties_class.PropertiesClass",
36+
"method_id": "read_only (get)",
37+
})
4238

4339

4440
def test_accessible_instrumented(events):
45-
from example_class import ExampleClass
41+
from properties_class import PropertiesClass
4642

47-
ec = ExampleClass()
48-
assert ExampleClass.fully_accessible.__doc__ == "Fully-accessible"
43+
ec = PropertiesClass()
44+
assert PropertiesClass.fully_accessible.__doc__ == "Fully-accessible"
4945

5046
assert ec.fully_accessible == "fully accessible"
5147

@@ -55,48 +51,61 @@ def test_accessible_instrumented(events):
5551

5652
del ec.fully_accessible
5753

58-
# assert len(events) == 6
59-
assert events[0].to_dict() == DictIncluding(
60-
{
61-
"event": "call",
62-
"defined_class": "example_class.ExampleClass",
63-
"method_id": "fully_accessible (get)",
64-
}
65-
)
66-
67-
assert events[2].to_dict() == DictIncluding(
68-
{
69-
"event": "call",
70-
"defined_class": "example_class.ExampleClass",
71-
"method_id": "fully_accessible (set)",
72-
}
73-
)
74-
75-
assert events[4].to_dict() == DictIncluding(
76-
{
77-
"event": "call",
78-
"defined_class": "example_class.ExampleClass",
79-
"method_id": "fully_accessible (del)",
80-
}
81-
)
54+
assert len(events) == 6
55+
assert events[0].to_dict() == DictIncluding({
56+
"event": "call",
57+
"defined_class": "properties_class.PropertiesClass",
58+
"method_id": "fully_accessible (get)",
59+
})
60+
61+
assert events[2].to_dict() == DictIncluding({
62+
"event": "call",
63+
"defined_class": "properties_class.PropertiesClass",
64+
"method_id": "fully_accessible (set)",
65+
})
66+
67+
assert events[4].to_dict() == DictIncluding({
68+
"event": "call",
69+
"defined_class": "properties_class.PropertiesClass",
70+
"method_id": "fully_accessible (del)",
71+
})
8272

8373

8474
def test_writable_instrumented(events):
85-
from example_class import ExampleClass
75+
from properties_class import PropertiesClass
8676

87-
ec = ExampleClass()
88-
assert ExampleClass.write_only.__doc__ == "Write-only"
77+
ec = PropertiesClass()
78+
assert PropertiesClass.write_only.__doc__ == "Write-only"
8979

9080
with pytest.raises(AttributeError, match=r".*(has no getter|unreadable attribute).*"):
9181
_ = ec.write_only
9282

9383
ec.write_only = "updated example"
9484

9585
assert len(events) == 2
96-
assert events[0].to_dict() == DictIncluding(
97-
{
98-
"event": "call",
99-
"defined_class": "example_class.ExampleClass",
100-
"method_id": "set_write_only (set)",
101-
}
102-
)
86+
assert events[0].to_dict() == DictIncluding({
87+
"event": "call",
88+
"defined_class": "properties_class.PropertiesClass",
89+
"method_id": "set_write_only (set)",
90+
})
91+
92+
93+
def test_operator_attrgetter(events):
94+
from properties_class import PropertiesClass
95+
96+
ec = PropertiesClass()
97+
98+
assert ec.operator_read_only == "read only"
99+
100+
with pytest.raises(AttributeError, match=r".*(has no setter|can't set attribute).*"):
101+
ec.operator_read_only = "not allowed"
102+
103+
with pytest.raises(AttributeError, match=r".*(has no deleter|can't delete attribute).*"):
104+
del ec.operator_read_only
105+
106+
assert len(events) == 2
107+
assert events[0].to_dict() == DictIncluding({
108+
"event": "call",
109+
"defined_class": "properties_class.PropertiesClass",
110+
"method_id": "operator_read_only (get)",
111+
})

0 commit comments

Comments
 (0)