diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a4dc774..7e612f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.15.0](https://github.com/getappmap/appmap-python/compare/v1.14.2...v1.15.0) (2023-03-13) + + +### Features + +* add schema to event parameters ([838f2de](https://github.com/getappmap/appmap-python/commit/838f2de8addd98f734e15ccc0ad90fc0d73553fc)) + ## [1.14.2](https://github.com/getappmap/appmap-python/compare/v1.14.1...v1.14.2) (2023-03-08) diff --git a/_appmap/event.py b/_appmap/event.py index cb8ebb0b..9cfa4202 100644 --- a/_appmap/event.py +++ b/_appmap/event.py @@ -64,14 +64,7 @@ def display_string(val): return value -def describe_value(val): - val_type = type(val) - ret = { - "class": fqname(val_type), - "object_id": id(val), - "value": display_string(val), - } - +def _is_list_or_dict(val_type): # We cannot use isinstance here because it uses __class__ # and val could be overloading it and calling it could cause side effects. # @@ -81,7 +74,45 @@ def describe_value(val): # If the object hasn't been evaluated before it could change the # observed behavior by doing that prematurely (perhaps even before # the evaluation can even succeed). - if issubclass(val_type, (list, dict)): + + return issubclass(val_type, list), issubclass(val_type, dict) + + +def _describe_schema(name, val, depth, max_depth): + + val_type = type(val) + + ret = {} + if name is not None: + ret["name"] = name + ret["class"] = fqname(val_type) + + islist, isdict = _is_list_or_dict(val_type) + if not (islist or isdict) or (depth >= max_depth and isdict): + return ret + + if islist: + elts = [(None, v) for v in val] + schema_key = "items" + elif isdict: + elts = val.items() + schema_key = "properties" + + schema = [_describe_schema(k, v, depth + 1, max_depth) for k, v in elts] + # schema will be [None] if depth is exceeded, don't use it + if any(schema): + ret[schema_key] = schema + + return ret + + +def describe_value(name, val, max_depth=5): + ret = { + "object_id": id(val), + "value": display_string(val), + } + ret.update(_describe_schema(name, val, 0, max_depth)) + if any(_is_list_or_dict(type(val))): ret["size"] = len(val) return ret @@ -136,8 +167,8 @@ def __repr__(self): return "" % (self.name, self.kind) def to_dict(self, value): - ret = {"name": self.name, "kind": self.kind} - ret.update(describe_value(value)) + ret = {"kind": self.kind} + ret.update(describe_value(self.name, value)) return ret @@ -321,8 +352,7 @@ def __init__(self, message_parameters): super().__init__("call") self.message = [] for name, value in message_parameters.items(): - message_object = {"name": name} - message_object.update(describe_value(value)) + message_object = describe_value(name, value) self.message.append(message_object) @@ -405,7 +435,7 @@ class FuncReturnEvent(ReturnEvent): def __init__(self, parent_id, elapsed, return_value): super().__init__(parent_id, elapsed) - self.return_value = describe_value(return_value) + self.return_value = describe_value(None, return_value) class HttpResponseEvent(ReturnEvent): diff --git a/_appmap/test/test_describe_value.py b/_appmap/test/test_describe_value.py new file mode 100644 index 00000000..9d790774 --- /dev/null +++ b/_appmap/test/test_describe_value.py @@ -0,0 +1,110 @@ +import pytest + +from _appmap.event import describe_value +from _appmap.test.helpers import DictIncluding + + +def test_describe_value_does_not_call_class(): + """describe_value should not call __class__ + __class__ could be overloaded in the value and + could cause side effects.""" + + class WithOverloadedClass: + # pylint: disable=missing-class-docstring,too-few-public-methods + @property + def __class__(self): + raise Exception("__class__ called") + + describe_value(None, WithOverloadedClass()) + + +class TestDictValue: + @pytest.fixture + def value(self): + return {"id": 1, "contents": "some text"} + + def test_one_level_schema(self, value): + actual = describe_value(None, value) + assert actual == DictIncluding( + { + "properties": [ + {"name": "id", "class": "builtins.int"}, + {"name": "contents", "class": "builtins.str"}, + ] + } + ) + + +class TestNestedDictValue: + @pytest.fixture + def value(self): + return {"page": {"page_number": 1, "page_size": 20, "total": 2383}} + + def test_two_level_schema(self, value): + actual = describe_value(None, value) + assert actual == DictIncluding( + { + "properties": [ + { + "name": "page", + "class": "builtins.dict", + "properties": [ + {"name": "page_number", "class": "builtins.int"}, + {"name": "page_size", "class": "builtins.int"}, + {"name": "total", "class": "builtins.int"}, + ], + } + ] + } + ) + + def test_respects_max_depth(self, value): + expected = {"properties": [{"name": "page", "class": "builtins.dict"}]} + actual = describe_value(None, value, max_depth=1) + assert actual == DictIncluding(expected) + + +class TestListOfDicts: + @pytest.fixture + def value(self): + return [{"id": 1, "contents": "some text"}, {"id": 2}] + + def test_an_array_containing_schema(self, value): + actual = describe_value(None, value) + assert actual["class"] == "builtins.list" + assert actual["items"][0] == DictIncluding( + { + "class": "builtins.dict", + "properties": [ + {"name": "id", "class": "builtins.int"}, + {"name": "contents", "class": "builtins.str"}, + ], + } + ) + assert actual["items"][1] == DictIncluding( + { + "class": "builtins.dict", + "properties": [{"name": "id", "class": "builtins.int"}], + } + ) + + +class TestNestedArrays: + @pytest.fixture + def value(self): + return [[["one"]]] + + def test_arrays_ignore_max_depth(self, value): + actual = describe_value(None, value, max_depth=1) + expected = { + "class": "builtins.list", + "items": [ + { + "class": "builtins.list", + "items": [ + {"class": "builtins.list", "items": [{"class": "builtins.str"}]} + ], + } + ], + } + assert actual == DictIncluding(expected) diff --git a/_appmap/test/test_events.py b/_appmap/test/test_events.py index 47099b73..ba45a3af 100644 --- a/_appmap/test/test_events.py +++ b/_appmap/test/test_events.py @@ -10,7 +10,7 @@ import appmap from _appmap.env import Env -from _appmap.event import _EventIds, describe_value +from _appmap.event import _EventIds # pylint: disable=import-error @@ -39,20 +39,6 @@ def add_thread_id(q): assert len(set(all_tids)) == len(all_tids) # Should all be unique -def test_describe_value_does_not_call_class(): - """describe_value should not call __class__ - __class__ could be overloaded in the value and - could cause side effects.""" - - class WithOverloadedClass: - # pylint: disable=missing-class-docstring,too-few-public-methods - @property - def __class__(self): - raise Exception("__class__ called") - - describe_value(WithOverloadedClass()) - - @pytest.mark.appmap_enabled @pytest.mark.usefixtures("with_data_dir") class TestEvents: diff --git a/_appmap/test/test_params.py b/_appmap/test/test_params.py index d80c5656..3b3c0339 100644 --- a/_appmap/test/test_params.py +++ b/_appmap/test/test_params.py @@ -258,6 +258,10 @@ def test_optional_2_keyword(self, params): "class": "builtins.dict", "kind": "keyrest", "size": 2, + "properties": [ + {"name": "p1", "class": "builtins.int"}, + {"name": "p2", "class": "builtins.int"}, + ], }, ) diff --git a/_appmap/web_framework.py b/_appmap/web_framework.py index b63d2b66..f72c8283 100644 --- a/_appmap/web_framework.py +++ b/_appmap/web_framework.py @@ -34,7 +34,7 @@ class TemplateEvent(Event): # pylint: disable=too-few-public-methods def __init__(self, path, instance=None): super().__init__("call") - self.receiver = describe_value(instance) + self.receiver = describe_value(None, instance) self.path = root_relative_path(path) def to_dict(self, attrs=None): diff --git a/pyproject.toml b/pyproject.toml index feed41c2..e30ff430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "appmap" -version = "1.14.2" +version = "1.15.0" description = "Create AppMap files by recording a Python application." readme = "README.md" authors = [