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/_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/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_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/test/test_test_frameworks.py b/_appmap/test/test_test_frameworks.py index e5093585..815e380b 100644 --- a/_appmap/test/test_test_frameworks.py +++ b/_appmap/test/test_test_frameworks.py @@ -7,10 +7,11 @@ import sys import types from abc import ABC, abstractmethod +from importlib.metadata import version as md_version from pathlib import Path import pytest - +from packaging import version from _appmap import recording from ..test.helpers import DictIncluding @@ -95,13 +96,14 @@ 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): 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,15 +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}" + 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): @@ -212,4 +217,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}" 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): 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 = [ 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