From d2af099c2aac9c14b78094a98117248aef1eea6d Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Tue, 2 Jul 2024 13:39:32 -0400 Subject: [PATCH 1/4] test: show appmap as JSON when diff fails When an actual appmap doesn't match the expected one, show it as JSON to make it easier to understand why they're different (and to assist with updating the expected, if appropriate). --- _appmap/test/test_test_frameworks.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/_appmap/test/test_test_frameworks.py b/_appmap/test/test_test_frameworks.py index e5093585..5f2f0779 100644 --- a/_appmap/test/test_test_frameworks.py +++ b/_appmap/test/test_test_frameworks.py @@ -11,7 +11,7 @@ import pytest -from _appmap import recording +from _appmap import recording, generation from ..test.helpers import DictIncluding from .normalize import normalize_appmap @@ -95,7 +95,7 @@ def setup_class(cls): cls._test_type = "pytest" def run_tests(self, testdir): - result = testdir.runpytest("-vv") + result = testdir.runpytest("-svv") result.assert_outcomes(passed=4, failed=2, xpassed=1, xfailed=1) def test_enabled(self, testdir): @@ -198,7 +198,9 @@ def verify_expected_appmap(testdir): appmap_json = testdir.expected / (f"{testdir.test_type}.appmap.json") expected_appmap = json.loads(appmap_json.read_text()) - assert generated_appmap == expected_appmap, f"expected appmap file {appmap_json}" + assert ( + generated_appmap == expected_appmap + ), f"expected appmap file {appmap_json}\ngenerated appmap: {json.dumps(generated_appmap, indent=2)}" def verify_expected_metadata(testdir): @@ -212,4 +214,6 @@ def verify_expected_metadata(testdir): name = pattern.search(file.name).group(1) metadata = json.loads(file.read_text())["metadata"] expected = testdir.expected / f"{name}.metadata.json" - assert metadata == DictIncluding(json.loads(expected.read_text())) + assert metadata == DictIncluding( + json.loads(expected.read_text()) + ), f"expected appmap: {file}" From 231af726d9302086655378df1e4fff951bf970fc Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Tue, 2 Jul 2024 15:25:02 -0400 Subject: [PATCH 2/4] test: make sure JSON serialization works With the previous function in simple.py, the return event generated to check that numpy.int64 serialized correctly didn't test AppMapEncoder. Keys in a dict that are in a return event are rendered with "repr" before the event gets serialized. "repr(int64(0))" returned "0" in 1.x, but returns "numpy.int64(0)" in 2.x, which caused the test to fail. --- ....appmap.json => pytest-numpy1.appmap.json} | 164 ++++++++---- .../pytest/expected/pytest-numpy2.appmap.json | 242 ++++++++++++++++++ .../expected/status_errored.metadata.json | 2 +- .../expected/status_failed.metadata.json | 2 +- .../expected/status_xfailed.metadata.json | 2 +- _appmap/test/data/pytest/simple.py | 21 +- _appmap/test/data/pytest/test_simple.py | 6 +- _appmap/test/test_test_frameworks.py | 19 +- tox.ini | 2 +- 9 files changed, 385 insertions(+), 75 deletions(-) rename _appmap/test/data/pytest/expected/{pytest.appmap.json => pytest-numpy1.appmap.json} (58%) create mode 100644 _appmap/test/data/pytest/expected/pytest-numpy2.appmap.json diff --git a/_appmap/test/data/pytest/expected/pytest.appmap.json b/_appmap/test/data/pytest/expected/pytest-numpy1.appmap.json similarity index 58% rename from _appmap/test/data/pytest/expected/pytest.appmap.json rename to _appmap/test/data/pytest/expected/pytest-numpy1.appmap.json index 8723789a..cd3ef53c 100644 --- a/_appmap/test/data/pytest/expected/pytest.appmap.json +++ b/_appmap/test/data/pytest/expected/pytest-numpy1.appmap.json @@ -8,75 +8,95 @@ "name": "appmap", "url": "https://github.com/applandinc/appmap-python" }, - "source_location": "test_simple.py:5", - "name": "hello world", - "feature": "Hello world", "app": "Simple", "recorder": { "name": "pytest", "type": "tests" }, + "source_location": "test_simple.py:5", + "name": "hello world", + "feature": "Hello world", "test_status": "succeeded" }, "events": [ { + "defined_class": "simple.Simple", + "method_id": "hello_world", + "path": "simple.py", + "lineno": 8, "static": false, "receiver": { + "class": "simple.Simple", "kind": "req", - "value": "", "name": "self", - "class": "simple.Simple" + "value": "" }, "parameters": [], "id": 1, "event": "call", - "thread_id": 1, - "defined_class": "simple.Simple", - "method_id": "hello_world", - "path": "simple.py", - "lineno": 16 + "thread_id": 1 }, { + "defined_class": "simple.Simple", + "method_id": "hello", + "path": "simple.py", + "lineno": 2, "static": false, "receiver": { + "class": "simple.Simple", "kind": "req", - "value": "", "name": "self", - "class": "simple.Simple" + "value": "" }, "parameters": [], "id": 2, "event": "call", - "thread_id": 1, - "defined_class": "simple.Simple", - "method_id": "get_non_json_serializable", - "path": "simple.py", - "lineno": 13 + "thread_id": 1 }, { + "return_value": { + "class": "builtins.str", + "value": "'Hello'" + }, + "parent_id": 2, + "id": 3, + "event": "return", + "thread_id": 1 + }, + { + "defined_class": "simple.Simple", + "method_id": "world", + "path": "simple.py", + "lineno": 5, "static": false, "receiver": { + "class": "simple.Simple", "kind": "req", - "value": "", "name": "self", - "class": "simple.Simple" + "value": "" }, "parameters": [], - "id": 3, + "id": 4, "event": "call", - "thread_id": 1, - "defined_class": "simple.Simple", - "method_id": "hello", - "path": "simple.py", - "lineno": 7 + "thread_id": 1 }, { "return_value": { - "value": "'Hello'", - "class": "builtins.str" + "class": "builtins.str", + "value": "'world!'" }, - "parent_id": 3, - "id": 4, + "parent_id": 4, + "id": 5, + "event": "return", + "thread_id": 1 + }, + { + "return_value": { + "class": "builtins.str", + "value": "'Hello world!'" + }, + "parent_id": 1, + "id": 6, "event": "return", "thread_id": 1 }, @@ -89,27 +109,52 @@ "class": "simple.Simple" }, "parameters": [], - "id": 5, + "id": 7, "event": "call", "thread_id": 1, "defined_class": "simple.Simple", - "method_id": "world", + "method_id": "show_numpy_dict", "path": "simple.py", - "lineno": 10 + "lineno": 11 }, { - "return_value": { - "value": "'world!'", - "class": "builtins.str" + "static": false, + "receiver": { + "kind": "req", + "value": "", + "name": "self", + "class": "simple.Simple" }, - "parent_id": 5, - "id": 6, - "event": "return", - "thread_id": 1 + "parameters": [ + { + "kind": "req", + "value": "{0: 'zero', 1: 'one'}", + "name": "d", + "class": "builtins.dict", + "properties": [ + { + "name": "0", + "class": "builtins.str" + }, + { + "name": "1", + "class": "builtins.str" + } + ], + "size": 2 + } + ], + "id": 8, + "event": "call", + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "get_numpy_dict", + "path": "simple.py", + "lineno": 18 }, { "return_value": { - "value": "{0: 'Hello', 1: 'world!'}", + "value": "{0: 'zero', 1: 'one'}", "class": "builtins.dict", "properties": [ { @@ -123,18 +168,29 @@ ], "size": 2 }, - "parent_id": 2, - "id": 7, + "parent_id": 8, + "id": 9, "event": "return", "thread_id": 1 }, { "return_value": { - "value": "'Hello world!'", - "class": "builtins.str" + "value": "{0: 'zero', 1: 'one'}", + "class": "builtins.dict", + "properties": [ + { + "name": "0", + "class": "builtins.str" + }, + { + "name": "1", + "class": "builtins.str" + } + ], + "size": 2 }, - "parent_id": 1, - "id": 8, + "parent_id": 7, + "id": 10, "event": "return", "thread_id": 1 } @@ -149,27 +205,33 @@ "type": "class", "children": [ { - "name": "get_non_json_serializable", + "name": "get_numpy_dict", "type": "function", - "location": "simple.py:13", + "location": "simple.py:18", "static": false }, { "name": "hello", "type": "function", - "location": "simple.py:7", + "location": "simple.py:2", "static": false }, { "name": "hello_world", "type": "function", - "location": "simple.py:16", + "location": "simple.py:8", + "static": false + }, + { + "name": "show_numpy_dict", + "type": "function", + "location": "simple.py:11", "static": false }, { "name": "world", "type": "function", - "location": "simple.py:10", + "location": "simple.py:5", "static": false } ] diff --git a/_appmap/test/data/pytest/expected/pytest-numpy2.appmap.json b/_appmap/test/data/pytest/expected/pytest-numpy2.appmap.json new file mode 100644 index 00000000..0f12e30c --- /dev/null +++ b/_appmap/test/data/pytest/expected/pytest-numpy2.appmap.json @@ -0,0 +1,242 @@ +{ + "version": "1.9", + "metadata": { + "language": { + "name": "python" + }, + "client": { + "name": "appmap", + "url": "https://github.com/applandinc/appmap-python" + }, + "app": "Simple", + "recorder": { + "name": "pytest", + "type": "tests" + }, + "source_location": "test_simple.py:5", + "name": "hello world", + "feature": "Hello world", + "test_status": "succeeded" + }, + "events": [ + { + "defined_class": "simple.Simple", + "method_id": "hello_world", + "path": "simple.py", + "lineno": 8, + "static": false, + "receiver": { + "class": "simple.Simple", + "kind": "req", + "name": "self", + "value": "" + }, + "parameters": [], + "id": 1, + "event": "call", + "thread_id": 1 + }, + { + "defined_class": "simple.Simple", + "method_id": "hello", + "path": "simple.py", + "lineno": 2, + "static": false, + "receiver": { + "class": "simple.Simple", + "kind": "req", + "name": "self", + "value": "" + }, + "parameters": [], + "id": 2, + "event": "call", + "thread_id": 1 + }, + { + "return_value": { + "class": "builtins.str", + "value": "'Hello'" + }, + "parent_id": 2, + "id": 3, + "event": "return", + "thread_id": 1 + }, + { + "defined_class": "simple.Simple", + "method_id": "world", + "path": "simple.py", + "lineno": 5, + "static": false, + "receiver": { + "class": "simple.Simple", + "kind": "req", + "name": "self", + "value": "" + }, + "parameters": [], + "id": 4, + "event": "call", + "thread_id": 1 + }, + { + "return_value": { + "class": "builtins.str", + "value": "'world!'" + }, + "parent_id": 4, + "id": 5, + "event": "return", + "thread_id": 1 + }, + { + "return_value": { + "class": "builtins.str", + "value": "'Hello world!'" + }, + "parent_id": 1, + "id": 6, + "event": "return", + "thread_id": 1 + }, + { + "static": false, + "receiver": { + "kind": "req", + "value": "", + "name": "self", + "class": "simple.Simple" + }, + "parameters": [], + "id": 7, + "event": "call", + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "show_numpy_dict", + "path": "simple.py", + "lineno": 11 + }, + { + "static": false, + "receiver": { + "kind": "req", + "value": "", + "name": "self", + "class": "simple.Simple" + }, + "parameters": [ + { + "kind": "req", + "value": "{np.int64(0): 'zero', np.int64(1): 'one'}", + "name": "d", + "class": "builtins.dict", + "properties": [ + { + "name": "0", + "class": "builtins.str" + }, + { + "name": "1", + "class": "builtins.str" + } + ], + "size": 2 + } + ], + "id": 8, + "event": "call", + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "get_numpy_dict", + "path": "simple.py", + "lineno": 18 + }, + { + "return_value": { + "value": "{np.int64(0): 'zero', np.int64(1): 'one'}", + "class": "builtins.dict", + "properties": [ + { + "name": "0", + "class": "builtins.str" + }, + { + "name": "1", + "class": "builtins.str" + } + ], + "size": 2 + }, + "parent_id": 8, + "id": 9, + "event": "return", + "thread_id": 1 + }, + { + "return_value": { + "value": "{np.int64(0): 'zero', np.int64(1): 'one'}", + "class": "builtins.dict", + "properties": [ + { + "name": "0", + "class": "builtins.str" + }, + { + "name": "1", + "class": "builtins.str" + } + ], + "size": 2 + }, + "parent_id": 7, + "id": 10, + "event": "return", + "thread_id": 1 + } + ], + "classMap": [ + { + "name": "simple", + "type": "package", + "children": [ + { + "name": "Simple", + "type": "class", + "children": [ + { + "name": "get_numpy_dict", + "type": "function", + "location": "simple.py:18", + "static": false + }, + { + "name": "hello", + "type": "function", + "location": "simple.py:2", + "static": false + }, + { + "name": "hello_world", + "type": "function", + "location": "simple.py:8", + "static": false + }, + { + "name": "show_numpy_dict", + "type": "function", + "location": "simple.py:11", + "static": false + }, + { + "name": "world", + "type": "function", + "location": "simple.py:5", + "static": false + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/_appmap/test/data/pytest/expected/status_errored.metadata.json b/_appmap/test/data/pytest/expected/status_errored.metadata.json index 45b3bed1..1c9d0f21 100644 --- a/_appmap/test/data/pytest/expected/status_errored.metadata.json +++ b/_appmap/test/data/pytest/expected/status_errored.metadata.json @@ -2,7 +2,7 @@ "test_status": "failed", "test_failure": { "message": "RuntimeError: test error", - "location": "test_simple.py:28" + "location": "test_simple.py:30" }, "exception": { "class": "RuntimeError", diff --git a/_appmap/test/data/pytest/expected/status_failed.metadata.json b/_appmap/test/data/pytest/expected/status_failed.metadata.json index cc971c33..cca17c0d 100644 --- a/_appmap/test/data/pytest/expected/status_failed.metadata.json +++ b/_appmap/test/data/pytest/expected/status_failed.metadata.json @@ -2,7 +2,7 @@ "test_status": "failed", "test_failure": { "message": "AssertionError: assert False", - "location": "test_simple.py:14" + "location": "test_simple.py:16" }, "exception": { "class": "AssertionError", diff --git a/_appmap/test/data/pytest/expected/status_xfailed.metadata.json b/_appmap/test/data/pytest/expected/status_xfailed.metadata.json index 992d824d..56494885 100644 --- a/_appmap/test/data/pytest/expected/status_xfailed.metadata.json +++ b/_appmap/test/data/pytest/expected/status_xfailed.metadata.json @@ -2,7 +2,7 @@ "test_status": "failed", "test_failure": { "message": "AssertionError: assert False", - "location": "test_simple.py:19" + "location": "test_simple.py:21" }, "exception": { "class": "AssertionError", diff --git a/_appmap/test/data/pytest/simple.py b/_appmap/test/data/pytest/simple.py index af0338eb..455c80a5 100644 --- a/_appmap/test/data/pytest/simple.py +++ b/_appmap/test/data/pytest/simple.py @@ -1,8 +1,3 @@ -import numpy - -zero = numpy.int64(0) -one = numpy.int64(1) - class Simple: def hello(self): return "Hello" @@ -10,9 +5,15 @@ def hello(self): def world(self): return "world!" - def get_non_json_serializable(self): - return { zero: self.hello(), one: self.world() } - def hello_world(self): - result = self.get_non_json_serializable() - return "%s %s" % (result[zero], result[one]) + return "%s %s" % (self.hello(), self.world()) + + def show_numpy_dict(self): + from numpy import int64 + + d = self.get_numpy_dict({int64(0): "zero", int64(1): "one"}) + print(d) + return d + + def get_numpy_dict(self, d): + return d \ No newline at end of file diff --git a/_appmap/test/data/pytest/test_simple.py b/_appmap/test/data/pytest/test_simple.py index c75c3593..05afaf4b 100644 --- a/_appmap/test/data/pytest/test_simple.py +++ b/_appmap/test/data/pytest/test_simple.py @@ -4,10 +4,12 @@ def test_hello_world(): - import simple + from simple import Simple os.chdir("/tmp") - assert simple.Simple().hello_world() == "Hello world!" + assert Simple().hello_world() == "Hello world!" + + assert len(Simple().show_numpy_dict()) > 0 def test_status_failed(): diff --git a/_appmap/test/test_test_frameworks.py b/_appmap/test/test_test_frameworks.py index 5f2f0779..815e380b 100644 --- a/_appmap/test/test_test_frameworks.py +++ b/_appmap/test/test_test_frameworks.py @@ -7,11 +7,12 @@ import sys import types from abc import ABC, abstractmethod +from importlib.metadata import version as md_version from pathlib import Path import pytest - -from _appmap import recording, generation +from packaging import version +from _appmap import recording from ..test.helpers import DictIncluding from .normalize import normalize_appmap @@ -101,7 +102,8 @@ def run_tests(self, testdir): def test_enabled(self, testdir): self.run_tests(testdir) assert len(list(testdir.output().iterdir())) == 6 - verify_expected_appmap(testdir) + numpy_version = version.parse(md_version("numpy")) + verify_expected_appmap(testdir, f"-numpy{numpy_version.major}") verify_expected_metadata(testdir) @@ -190,17 +192,18 @@ def output_dir(): return pytester -def verify_expected_appmap(testdir): +def verify_expected_appmap(testdir, suffix=""): appmap_json = list(testdir.output().glob("*test_hello_world.appmap.json")) assert len(appmap_json) == 1 # sanity check generated_appmap = normalize_appmap(appmap_json[0].read_text()) - appmap_json = testdir.expected / (f"{testdir.test_type}.appmap.json") + appmap_json = testdir.expected / (f"{testdir.test_type}{suffix}.appmap.json") expected_appmap = json.loads(appmap_json.read_text()) - assert ( - generated_appmap == expected_appmap - ), f"expected appmap file {appmap_json}\ngenerated appmap: {json.dumps(generated_appmap, indent=2)}" + assert generated_appmap == expected_appmap, ( + f"expected appmap file {appmap_json}\n" + + f"generated appmap: {json.dumps(generated_appmap, indent=2)}" + ) def verify_expected_metadata(testdir): diff --git a/tox.ini b/tox.ini index faeb02af..a5ef8a44 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ deps= poetry web: {[web-deps]deps} py38: numpy==1.24.4 - py3{9,10,11,12}: numpy >=1.26 + py3{9,10,11,12}: numpy >=2 flask2: Flask >= 2.0, <3.0 django3: Django >=3.2, <4.0 sqlalchemy1: sqlalchemy >=1.4.11, <2.0 From d69b6e1648bd647b91ca4f9ef75300af7e015bfb Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Mon, 1 Jul 2024 06:54:35 -0400 Subject: [PATCH 3/4] feat: instrument properties Any class function decorated with @property, or any class attribute with a property as a value, will now be instrumented. --- _appmap/event.py | 14 +++- _appmap/importer.py | 55 +++++++++++---- _appmap/test/data/example_class.py | 47 +++++++++++++ _appmap/test/test_params.py | 2 +- _appmap/test/test_properties.py | 105 +++++++++++++++++++++++++++++ _appmap/utils.py | 4 ++ 6 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 _appmap/test/test_properties.py diff --git a/_appmap/event.py b/_appmap/event.py index f53102d2..a333fc78 100644 --- a/_appmap/event.py +++ b/_appmap/event.py @@ -176,7 +176,7 @@ def to_dict(self, value): class CallEvent(Event): # pylint: disable=method-cache-max-size-none - __slots__ = ["_fn", "_fqfn", "static", "receiver", "parameters", "labels"] + __slots__ = ["_fn", "_fqfn", "static", "receiver", "parameters", "labels", "auxtype"] @staticmethod def make(fn, fntype): @@ -283,7 +283,10 @@ def defined_class(self): @property @lru_cache(maxsize=None) def method_id(self): - return self._fqfn.fqfn[1] + ret = self._fqfn.fqfn[1] + if self.auxtype is not None: + ret = f"{ret} ({self.auxtype})" + return ret @property @lru_cache(maxsize=None) @@ -319,6 +322,13 @@ def __init__(self, fn, fntype, parameters, labels): parameters = parameters[1:] self.parameters = parameters self.labels = labels + self.auxtype = None + if fntype & FnType.GET: + self.auxtype = "get" + elif fntype & FnType.SET: + self.auxtype = "set" + elif fntype & FnType.DEL: + self.auxtype = "del" def to_dict(self, attrs=None): ret = super().to_dict() # get the attrs defined in __slots__ diff --git a/_appmap/importer.py b/_appmap/importer.py index cecdf48a..6cff6cdd 100644 --- a/_appmap/importer.py +++ b/_appmap/importer.py @@ -37,14 +37,14 @@ def __new__(cls, clazz): class FilterableFn( namedtuple( "FilterableFn", - Filterable._fields + ("static_fn",), + Filterable._fields + ("static_fn", "auxtype"), ) ): __slots__ = () - def __new__(cls, scope, fn, static_fn): + def __new__(cls, scope, fn, static_fn, auxtype=None): fqname = "%s.%s" % (scope.fqname, fn.__name__) - self = super(FilterableFn, cls).__new__(cls, scope.scope, fqname, fn, static_fn) + self = super(FilterableFn, cls).__new__(cls, scope.scope, fqname, fn, static_fn, auxtype) return self @property @@ -52,7 +52,10 @@ def fntype(self): if self.scope == Scope.MODULE: return FnType.MODULE - return FnType.classify(self.static_fn) + ret = FnType.classify(self.static_fn) + if self.auxtype is not None: + ret |= self.auxtype + return ret class Filter(ABC): # pylint: disable=too-few-public-methods @@ -122,19 +125,31 @@ def is_member_func(m): # instead iterate over dir(cls), we would see functions from # superclasses, too. Those don't need to be instrumented here, # they'll get taken care of when the superclass is imported. - ret = [] + functions = [] + properties = {} modname = cls.__module__ if hasattr(cls, "__module__") else cls.__name__ for key in cls.__dict__: if key.startswith("__"): continue static_value = inspect.getattr_static(cls, key) - if not is_member_func(static_value): - continue - value = getattr(cls, key) - if value.__module__ != modname: - continue - ret.append((key, static_value, value)) - return ret + if isinstance(static_value, property): + properties[key] = ( + static_value, + { + "fget": (static_value.fget, FnType.GET), + "fset": (static_value.fset, FnType.SET), + "fdel": (static_value.fdel, FnType.DEL), + }, + ) + else: + if not is_member_func(static_value): + continue + value = getattr(cls, key) + if value.__module__ != modname: + continue + functions.append((key, static_value, value)) + + return (functions, properties) class Importer: @@ -177,7 +192,7 @@ def do_import(cls, *args, **kwargs): def instrument_functions(filterable, selected_functions=None): logger.trace(" looking for members of %s", filterable.obj) - functions = get_members(filterable.obj) + functions, properties = get_members(filterable.obj) logger.trace(" functions %s", functions) for fn_name, static_fn, fn in functions: @@ -185,6 +200,20 @@ def instrument_functions(filterable, selected_functions=None): new_fn = cls.instrument_function(fn_name, filterableFn, selected_functions) if new_fn != fn: wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn) + # Now that we've instrumented all the functions, go through the properties and update + # them + for prop_name, (prop, prop_fns) in properties.items(): + instrumented_fns = {} + for k, (fn, auxtype) in prop_fns.items(): + if fn is None: + continue + filterableFn = FilterableFn(filterable, fn, fn, auxtype) + new_fn = cls.instrument_function(fn.__name__, filterableFn, selected_functions) + if new_fn != fn: + new_fn = wrapt.FunctionWrapper(fn, new_fn) + instrumented_fns[k] = new_fn + instrumented_fns["doc"] = prop.__doc__ + setattr(filterable.obj, prop_name, property(**instrumented_fns)) # Import Config here, to avoid circular top-level imports. from .configuration import Config # pylint: disable=import-outside-toplevel diff --git a/_appmap/test/data/example_class.py b/_appmap/test/data/example_class.py index 3e61f7ee..46c124f5 100644 --- a/_appmap/test/data/example_class.py +++ b/_appmap/test/data/example_class.py @@ -113,6 +113,53 @@ def with_comment(self): def return_self(self): return self + def __init__(self): + self._read_only = "read only" + self._fully_accessible = "fully accessible" + self._undecorated = "undecorated" + + @property + def read_only(self): + """Read-only""" + return self._read_only + + @property + def fully_accessible(self): + """Fully-accessible""" + return self._fully_accessible + + @fully_accessible.setter + def fully_accessible(self, v): + self._fully_accessible = v + + @fully_accessible.deleter + def fully_accessible(self): + del self._fully_accessible + + def get_undecorated(self): + return self._undecorated + + def set_undecorated(self, value): + self._undecorated = value + + def delete_undecorated(self): + del self._undecorated + + undecorated_property = property(get_undecorated, set_undecorated, delete_undecorated) + + def set_write_only(self, v): + self._write_only = v + + def del_write_only(self): + del self._write_only + + write_only = property(None, set_write_only, del_write_only, "Write-only") + def modfunc(): return "Hello world!" + +if __name__ == "__main__": + ec = ExampleClass() + ec.fully_accessible = "updated" + print(ec.fully_accessible) \ No newline at end of file diff --git a/_appmap/test/test_params.py b/_appmap/test/test_params.py index cbe572bc..7026755b 100644 --- a/_appmap/test/test_params.py +++ b/_appmap/test/test_params.py @@ -1,4 +1,4 @@ -"""Tests for the function parameter handling""" +"""Tests for function parameter handling""" # pylint: disable=missing-function-docstring diff --git a/_appmap/test/test_properties.py b/_appmap/test/test_properties.py new file mode 100644 index 00000000..c22ae029 --- /dev/null +++ b/_appmap/test/test_properties.py @@ -0,0 +1,105 @@ +"""Tests for methods decorated with @property""" + +# pyright: reportMissingImports=false +# pylint: disable=import-error,import-outside-toplevel +import pytest +from _appmap.test.helpers import DictIncluding + +pytestmark = [ + pytest.mark.appmap_enabled, +] + + +@pytest.fixture(autouse=True) +def setup(with_data_dir): # pylint: disable=unused-argument + # with_data_dir sets up sys.path so example_class can be imported + pass + + +def test_getter_instrumented(events): + from example_class import ExampleClass + + ec = ExampleClass() + + actual = ExampleClass.read_only.__doc__ + assert actual == "Read-only" + + assert ec.read_only == "read only" + + with pytest.raises(AttributeError, match=r".*(has no setter|can't set attribute).*"): + # E AttributeError: can't set attribute + + ec.read_only = "not allowed" + + with pytest.raises(AttributeError, match=r".*(has no deleter|can't delete attribute).*"): + del ec.read_only + + assert len(events) == 2 + assert events[0].to_dict() == DictIncluding( + { + "event": "call", + "defined_class": "example_class.ExampleClass", + "method_id": "read_only (get)", + } + ) + + +def test_accessible_instrumented(events): + from example_class import ExampleClass + + ec = ExampleClass() + assert ExampleClass.fully_accessible.__doc__ == "Fully-accessible" + + assert ec.fully_accessible == "fully accessible" + + ec.fully_accessible = "updated" + # Check the value of the attribute directly, to avoid extra events + assert ec._fully_accessible == "updated" # pylint: disable=protected-access + + del ec.fully_accessible + + # assert len(events) == 6 + assert events[0].to_dict() == DictIncluding( + { + "event": "call", + "defined_class": "example_class.ExampleClass", + "method_id": "fully_accessible (get)", + } + ) + + assert events[2].to_dict() == DictIncluding( + { + "event": "call", + "defined_class": "example_class.ExampleClass", + "method_id": "fully_accessible (set)", + } + ) + + assert events[4].to_dict() == DictIncluding( + { + "event": "call", + "defined_class": "example_class.ExampleClass", + "method_id": "fully_accessible (del)", + } + ) + + +def test_writable_instrumented(events): + from example_class import ExampleClass + + ec = ExampleClass() + assert ExampleClass.write_only.__doc__ == "Write-only" + + with pytest.raises(AttributeError, match=r".*(has no getter|unreadable attribute).*"): + _ = ec.write_only + + ec.write_only = "updated example" + + assert len(events) == 2 + assert events[0].to_dict() == DictIncluding( + { + "event": "call", + "defined_class": "example_class.ExampleClass", + "method_id": "set_write_only (set)", + } + ) diff --git a/_appmap/utils.py b/_appmap/utils.py index 7ac193a6..0b39e947 100644 --- a/_appmap/utils.py +++ b/_appmap/utils.py @@ -35,6 +35,10 @@ class FnType(IntFlag): CLASS = auto() INSTANCE = auto() MODULE = auto() + # auxtypes + GET = auto() + SET = auto() + DEL = auto() @staticmethod def classify(fn): From 660bcdc2f838d86b3425eafcc07773a380d9521a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 3 Jul 2024 15:08:30 +0000 Subject: [PATCH 4/4] chore(release): 2.1.0 [skip ci] # [2.1.0](https://github.com/getappmap/appmap-python/compare/v2.0.10...v2.1.0) (2024-07-03) ### Features * instrument properties ([d69b6e1](https://github.com/getappmap/appmap-python/commit/d69b6e1648bd647b91ca4f9ef75300af7e015bfb)) --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e88aba07..7fd69e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [2.1.0](https://github.com/getappmap/appmap-python/compare/v2.0.10...v2.1.0) (2024-07-03) + + +### Features + +* instrument properties ([d69b6e1](https://github.com/getappmap/appmap-python/commit/d69b6e1648bd647b91ca4f9ef75300af7e015bfb)) + ## [2.0.10](https://github.com/getappmap/appmap-python/compare/v2.0.9...v2.0.10) (2024-06-21) diff --git a/pyproject.toml b/pyproject.toml index c1a369c7..7c70bfd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "appmap" -version = "2.0.10" +version = "2.1.0" description = "Create AppMap files by recording a Python application." readme = "README.md" authors = [