Skip to content

Commit 72d96aa

Browse files
authored
Merge pull request getappmap#344 from getappmap/props_20240626
feat: instrument properties
2 parents 4bc86ba + d69b6e1 commit 72d96aa

File tree

6 files changed

+211
-16
lines changed

6 files changed

+211
-16
lines changed

_appmap/event.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def to_dict(self, value):
176176

177177
class CallEvent(Event):
178178
# pylint: disable=method-cache-max-size-none
179-
__slots__ = ["_fn", "_fqfn", "static", "receiver", "parameters", "labels"]
179+
__slots__ = ["_fn", "_fqfn", "static", "receiver", "parameters", "labels", "auxtype"]
180180

181181
@staticmethod
182182
def make(fn, fntype):
@@ -283,7 +283,10 @@ def defined_class(self):
283283
@property
284284
@lru_cache(maxsize=None)
285285
def method_id(self):
286-
return self._fqfn.fqfn[1]
286+
ret = self._fqfn.fqfn[1]
287+
if self.auxtype is not None:
288+
ret = f"{ret} ({self.auxtype})"
289+
return ret
287290

288291
@property
289292
@lru_cache(maxsize=None)
@@ -319,6 +322,13 @@ def __init__(self, fn, fntype, parameters, labels):
319322
parameters = parameters[1:]
320323
self.parameters = parameters
321324
self.labels = labels
325+
self.auxtype = None
326+
if fntype & FnType.GET:
327+
self.auxtype = "get"
328+
elif fntype & FnType.SET:
329+
self.auxtype = "set"
330+
elif fntype & FnType.DEL:
331+
self.auxtype = "del"
322332

323333
def to_dict(self, attrs=None):
324334
ret = super().to_dict() # get the attrs defined in __slots__

_appmap/importer.py

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,25 @@ def __new__(cls, clazz):
3737
class FilterableFn(
3838
namedtuple(
3939
"FilterableFn",
40-
Filterable._fields + ("static_fn",),
40+
Filterable._fields + ("static_fn", "auxtype"),
4141
)
4242
):
4343
__slots__ = ()
4444

45-
def __new__(cls, scope, fn, static_fn):
45+
def __new__(cls, scope, fn, static_fn, auxtype=None):
4646
fqname = "%s.%s" % (scope.fqname, fn.__name__)
47-
self = super(FilterableFn, cls).__new__(cls, scope.scope, fqname, fn, static_fn)
47+
self = super(FilterableFn, cls).__new__(cls, scope.scope, fqname, fn, static_fn, auxtype)
4848
return self
4949

5050
@property
5151
def fntype(self):
5252
if self.scope == Scope.MODULE:
5353
return FnType.MODULE
5454

55-
return FnType.classify(self.static_fn)
55+
ret = FnType.classify(self.static_fn)
56+
if self.auxtype is not None:
57+
ret |= self.auxtype
58+
return ret
5659

5760

5861
class Filter(ABC): # pylint: disable=too-few-public-methods
@@ -122,19 +125,31 @@ def is_member_func(m):
122125
# instead iterate over dir(cls), we would see functions from
123126
# superclasses, too. Those don't need to be instrumented here,
124127
# they'll get taken care of when the superclass is imported.
125-
ret = []
128+
functions = []
129+
properties = {}
126130
modname = cls.__module__ if hasattr(cls, "__module__") else cls.__name__
127131
for key in cls.__dict__:
128132
if key.startswith("__"):
129133
continue
130134
static_value = inspect.getattr_static(cls, key)
131-
if not is_member_func(static_value):
132-
continue
133-
value = getattr(cls, key)
134-
if value.__module__ != modname:
135-
continue
136-
ret.append((key, static_value, value))
137-
return ret
135+
if isinstance(static_value, property):
136+
properties[key] = (
137+
static_value,
138+
{
139+
"fget": (static_value.fget, FnType.GET),
140+
"fset": (static_value.fset, FnType.SET),
141+
"fdel": (static_value.fdel, FnType.DEL),
142+
},
143+
)
144+
else:
145+
if not is_member_func(static_value):
146+
continue
147+
value = getattr(cls, key)
148+
if value.__module__ != modname:
149+
continue
150+
functions.append((key, static_value, value))
151+
152+
return (functions, properties)
138153

139154

140155
class Importer:
@@ -177,14 +192,28 @@ def do_import(cls, *args, **kwargs):
177192

178193
def instrument_functions(filterable, selected_functions=None):
179194
logger.trace(" looking for members of %s", filterable.obj)
180-
functions = get_members(filterable.obj)
195+
functions, properties = get_members(filterable.obj)
181196
logger.trace(" functions %s", functions)
182197

183198
for fn_name, static_fn, fn in functions:
184199
filterableFn = FilterableFn(filterable, fn, static_fn)
185200
new_fn = cls.instrument_function(fn_name, filterableFn, selected_functions)
186201
if new_fn != fn:
187202
wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn)
203+
# Now that we've instrumented all the functions, go through the properties and update
204+
# them
205+
for prop_name, (prop, prop_fns) in properties.items():
206+
instrumented_fns = {}
207+
for k, (fn, auxtype) in prop_fns.items():
208+
if fn is None:
209+
continue
210+
filterableFn = FilterableFn(filterable, fn, fn, auxtype)
211+
new_fn = cls.instrument_function(fn.__name__, filterableFn, selected_functions)
212+
if new_fn != fn:
213+
new_fn = wrapt.FunctionWrapper(fn, new_fn)
214+
instrumented_fns[k] = new_fn
215+
instrumented_fns["doc"] = prop.__doc__
216+
setattr(filterable.obj, prop_name, property(**instrumented_fns))
188217

189218
# Import Config here, to avoid circular top-level imports.
190219
from .configuration import Config # pylint: disable=import-outside-toplevel

_appmap/test/data/example_class.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,53 @@ def with_comment(self):
113113
def return_self(self):
114114
return self
115115

116+
def __init__(self):
117+
self._read_only = "read only"
118+
self._fully_accessible = "fully accessible"
119+
self._undecorated = "undecorated"
120+
121+
@property
122+
def read_only(self):
123+
"""Read-only"""
124+
return self._read_only
125+
126+
@property
127+
def fully_accessible(self):
128+
"""Fully-accessible"""
129+
return self._fully_accessible
130+
131+
@fully_accessible.setter
132+
def fully_accessible(self, v):
133+
self._fully_accessible = v
134+
135+
@fully_accessible.deleter
136+
def fully_accessible(self):
137+
del self._fully_accessible
138+
139+
def get_undecorated(self):
140+
return self._undecorated
141+
142+
def set_undecorated(self, value):
143+
self._undecorated = value
144+
145+
def delete_undecorated(self):
146+
del self._undecorated
147+
148+
undecorated_property = property(get_undecorated, set_undecorated, delete_undecorated)
149+
150+
def set_write_only(self, v):
151+
self._write_only = v
152+
153+
def del_write_only(self):
154+
del self._write_only
155+
156+
write_only = property(None, set_write_only, del_write_only, "Write-only")
157+
116158

117159
def modfunc():
118160
return "Hello world!"
161+
162+
if __name__ == "__main__":
163+
ec = ExampleClass()
164+
ec.fully_accessible = "updated"
165+
print(ec.fully_accessible)

_appmap/test/test_params.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for the function parameter handling"""
1+
"""Tests for function parameter handling"""
22

33
# pylint: disable=missing-function-docstring
44

_appmap/test/test_properties.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Tests for methods decorated with @property"""
2+
3+
# pyright: reportMissingImports=false
4+
# pylint: disable=import-error,import-outside-toplevel
5+
import pytest
6+
from _appmap.test.helpers import DictIncluding
7+
8+
pytestmark = [
9+
pytest.mark.appmap_enabled,
10+
]
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def setup(with_data_dir): # pylint: disable=unused-argument
15+
# with_data_dir sets up sys.path so example_class can be imported
16+
pass
17+
18+
19+
def test_getter_instrumented(events):
20+
from example_class import ExampleClass
21+
22+
ec = ExampleClass()
23+
24+
actual = ExampleClass.read_only.__doc__
25+
assert actual == "Read-only"
26+
27+
assert ec.read_only == "read only"
28+
29+
with pytest.raises(AttributeError, match=r".*(has no setter|can't set attribute).*"):
30+
# E AttributeError: can't set attribute
31+
32+
ec.read_only = "not allowed"
33+
34+
with pytest.raises(AttributeError, match=r".*(has no deleter|can't delete attribute).*"):
35+
del ec.read_only
36+
37+
assert len(events) == 2
38+
assert events[0].to_dict() == DictIncluding(
39+
{
40+
"event": "call",
41+
"defined_class": "example_class.ExampleClass",
42+
"method_id": "read_only (get)",
43+
}
44+
)
45+
46+
47+
def test_accessible_instrumented(events):
48+
from example_class import ExampleClass
49+
50+
ec = ExampleClass()
51+
assert ExampleClass.fully_accessible.__doc__ == "Fully-accessible"
52+
53+
assert ec.fully_accessible == "fully accessible"
54+
55+
ec.fully_accessible = "updated"
56+
# Check the value of the attribute directly, to avoid extra events
57+
assert ec._fully_accessible == "updated" # pylint: disable=protected-access
58+
59+
del ec.fully_accessible
60+
61+
# assert len(events) == 6
62+
assert events[0].to_dict() == DictIncluding(
63+
{
64+
"event": "call",
65+
"defined_class": "example_class.ExampleClass",
66+
"method_id": "fully_accessible (get)",
67+
}
68+
)
69+
70+
assert events[2].to_dict() == DictIncluding(
71+
{
72+
"event": "call",
73+
"defined_class": "example_class.ExampleClass",
74+
"method_id": "fully_accessible (set)",
75+
}
76+
)
77+
78+
assert events[4].to_dict() == DictIncluding(
79+
{
80+
"event": "call",
81+
"defined_class": "example_class.ExampleClass",
82+
"method_id": "fully_accessible (del)",
83+
}
84+
)
85+
86+
87+
def test_writable_instrumented(events):
88+
from example_class import ExampleClass
89+
90+
ec = ExampleClass()
91+
assert ExampleClass.write_only.__doc__ == "Write-only"
92+
93+
with pytest.raises(AttributeError, match=r".*(has no getter|unreadable attribute).*"):
94+
_ = ec.write_only
95+
96+
ec.write_only = "updated example"
97+
98+
assert len(events) == 2
99+
assert events[0].to_dict() == DictIncluding(
100+
{
101+
"event": "call",
102+
"defined_class": "example_class.ExampleClass",
103+
"method_id": "set_write_only (set)",
104+
}
105+
)

_appmap/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ class FnType(IntFlag):
3535
CLASS = auto()
3636
INSTANCE = auto()
3737
MODULE = auto()
38+
# auxtypes
39+
GET = auto()
40+
SET = auto()
41+
DEL = auto()
3842

3943
@staticmethod
4044
def classify(fn):

0 commit comments

Comments
 (0)