diff --git a/_appmap/event.py b/_appmap/event.py index a61a5a04..0bb254cf 100644 --- a/_appmap/event.py +++ b/_appmap/event.py @@ -6,6 +6,7 @@ from functools import lru_cache, partial from inspect import Parameter, Signature from itertools import chain +import types from .env import Env from .recorder import Recorder @@ -175,6 +176,31 @@ def to_dict(self, value): ret.update(describe_value(self.name, value)) return ret +def _get_name_parts(filterable): + """ + Return the module name and qualname for filterable.obj. + + If filterable.obj is an operator.attrgetter that we've determined is associated with a property, + compute the names from the fqname of the filterable. If it's anything else, try to get its + __module__ and __qualname__, falling back to default values if they're not available. + """ + fn = filterable.obj + assert callable(fn), f"{filterable} doesn't have a callable obj" + + if ( + type(fn) is operator.attrgetter + and (parts := filterable.fqname.split(".")) + and len(parts) > 2 + ): + # filterable.fqname was set when we identified this filterable as a property + modname = ".".join(parts[:-2]) + qualname = ".".join(parts[-2:]) + else: + modname = getattr(fn, "__module__", "unknown") + qualname = getattr(fn, "__qualname__", None) + if qualname is None: + qualname = getattr(fn.__class__, "__name__", "unknown") + return modname, qualname class CallEvent(Event): # pylint: disable=method-cache-max-size-none @@ -318,21 +344,7 @@ def __init__(self, filterable, parameters, labels): super().__init__("call") fn = filterable.obj self._fn = fn - if type(fn) is not operator.attrgetter: - # fn is a regular function - modname = fn.__module__ - qualname = fn.__qualname__ - elif (parts := filterable.fqname.split(".")) and len(parts) > 2: - # fn is an attrgetter, which will is being used as the getter for a property. If - # filterable.fqname has enough components to be the fully-qualified name of a class - # member, set the module name and qualname based on those components. - modname = ".".join(parts[:-2]) - qualname = ".".join(parts[-2:]) - else: - # The two previous cases should handle all known possibilities, but don't crash if - # somethign else sneaks in. - modname = "unknown" - qualname = "unknown" + modname, qualname = _get_name_parts(filterable) self._fqfn = FqFnName(modname, qualname) fntype = filterable.fntype diff --git a/_appmap/test/data/properties_class.py b/_appmap/test/data/properties_class.py index ab81f898..e6a3f119 100644 --- a/_appmap/test/data/properties_class.py +++ b/_appmap/test/data/properties_class.py @@ -1,8 +1,12 @@ -from functools import cached_property +from functools import cached_property, partial import operator from typing import NoReturn +def free_read_only(self): + return self._read_only +def free_func(): + return "hello world" class PropertiesClass: def __init__(self): self._read_only = "read only" @@ -54,3 +58,14 @@ def cached_read_only(self): return self._read_only operator_read_only = property(operator.attrgetter("cached_read_only")) + + tastes = {"bacon": "yum"} + + def __getitem__(self, key): + return self.tastes[key] + + taste = property(operator.itemgetter("bacon")) + + free_read_only_prop = property(free_read_only) + + static_partial_method = staticmethod(partial(free_func)) diff --git a/_appmap/test/test_properties.py b/_appmap/test/test_properties.py index 565d0566..b1039836 100644 --- a/_appmap/test/test_properties.py +++ b/_appmap/test/test_properties.py @@ -109,3 +109,39 @@ def test_operator_attrgetter(events): "defined_class": "properties_class.PropertiesClass", "method_id": "operator_read_only (get)", }) + +def test_operator_itemgetter(events): + from properties_class import PropertiesClass + + ec = PropertiesClass() + assert ec.taste == "yum" + assert len(events) == 2 + assert events[0].to_dict() == DictIncluding({ + "event": "call", + "defined_class": "operator", + "method_id": "itemgetter (get)", + }) + + +def test_free_function(events): + from properties_class import PropertiesClass + + ec = PropertiesClass() + assert ec.free_read_only_prop == "read only" + assert len(events) == 2 + assert events[0].to_dict() == DictIncluding({ + "event": "call", + "defined_class": "properties_class", + "method_id": "free_read_only (get)", + }) + + +@pytest.mark.xfail( + raises=AssertionError, + reason="needs fix for https://github.com/getappmap/appmap-python/issues/365", +) +def test_functools_partial(events): + from properties_class import PropertiesClass + + PropertiesClass.static_partial_method() + assert len(events) > 0 \ No newline at end of file