diff --git a/_appmap/configuration.py b/_appmap/configuration.py index 870c2aec..e56b18f0 100644 --- a/_appmap/configuration.py +++ b/_appmap/configuration.py @@ -7,12 +7,14 @@ import inspect import json import os +import re import sys from os.path import realpath from pathlib import Path from textwrap import dedent import yaml +from yaml import SafeLoader from yaml.parser import ParserError from _appmap.labels import LabelSet @@ -50,20 +52,20 @@ def _get_sys_prefix(): return realpath(sys.prefix) +_EXCLUDE_PATTERN = re.compile(r"\..*|node_modules|.*test.*|site-packages") + + def find_top_packages(rootdir): """ - Scan a directory tree for packages that should appear in the - default config file. + Scan a directory tree for packages that should appear in the default config file. - Examine directories in rootdir, to see if they contains an - __init__.py. If it does, add it to the list of packages and don't - scan any of its subdirectories. If it doesn't, scan its + Examine each directory in rootdir, to see if it contains an __init__.py. If it does, add it to + the list of packages and don't scan any of its subdirectories. If it doesn't, scan its subdirectories to find __init__.py. - Some directories are automatically excluded from the search: - * sys.prefix - * Hidden directories (i.e. those that start with a '.') - * node_modules + Directory traversal will stop at directories that match _EXCLUDE_PATTERN. Such a directory (and + its subdirectories) will not be added to the returned packages, but will in set of excluded + directories). For example, in a directory like this @@ -104,11 +106,11 @@ def find_top_packages(rootdir): # build process copies its source to a subdirectory. packages = set() - def excluded(d): - excluded = d == "node_modules" or d[0] == "." - if excluded: + def exclude(d): + is_excluded = _EXCLUDE_PATTERN.search(d) is not None + if is_excluded: logger.trace("excluding dir %s", d) - return excluded + return is_excluded sys_prefix = _get_sys_prefix() @@ -123,13 +125,26 @@ def excluded(d): packages.add(Path(d).name) dirs.clear() else: - dirs[:] = [d for d in dirs if not excluded(d)] + dirs[:] = [d for d in dirs if not exclude(d)] return packages class AppMapInvalidConfigException(Exception): pass +# We don't have any control over the PyYAML class hierarchy, so we can't control how many ancestors +# SafeLoader has.... +class _ConfigLoader(SafeLoader): # pylint: disable=too-many-ancestors + def construct_mapping(self, node, deep=False): + mapping = super().construct_mapping(node, deep=deep) + # Allow record_test_cases to be set using a string (in addition to allowing a boolean). + if "record_test_cases" in mapping: + val = mapping["record_test_cases"] + if isinstance(val, str): + mapping["record_test_cases"] = val.lower() == "true" + return mapping + + class Config(metaclass=SingletonMeta): """Singleton Config class""" @@ -156,11 +171,16 @@ def name(self): def packages(self): return self._config["packages"] + @property + def record_test_cases(self): + return self._config.get("record_test_cases", False) + @property def default(self): ret = { "name": self.default_name, "language": "python", + "record_test_cases": False, "packages": self.default_packages, } env = Env.current @@ -233,7 +253,7 @@ def _load_config(self, show_warnings=False): Env.current.enabled = False self.file_valid = False try: - self._config = yaml.safe_load(path.read_text(encoding="utf-8")) + self._config = yaml.load(path.read_text(encoding="utf-8"), Loader=_ConfigLoader) if not self._config: # It parsed, but was (effectively) empty. self._config = self.default @@ -334,7 +354,6 @@ def _check_path_value(self, value): except SyntaxError: return False - def startswith(prefix, sequence): """ Check if a sequence starts with the prefix. @@ -377,7 +396,8 @@ class DistMatcher(PathMatcher): def __init__(self, dist, *args, **kwargs): super().__init__(*args, **kwargs) self.dist = dist - self.files = [str(pp.locate()) for pp in importlib.metadata.files(dist)] + dist_files = importlib.metadata.files(dist) + self.files = [str(pp.locate()) for pp in dist_files] if dist_files is not None else [] def matches(self, filterable): try: diff --git a/_appmap/test/data/pytest/appmap-no-test-cases.yml b/_appmap/test/data/pytest/appmap-no-test-cases.yml new file mode 100644 index 00000000..4e0eb415 --- /dev/null +++ b/_appmap/test/data/pytest/appmap-no-test-cases.yml @@ -0,0 +1,4 @@ +name: Simple +record_test_cases: false +packages: +- path: simple diff --git a/_appmap/test/data/pytest/appmap.yml b/_appmap/test/data/pytest/appmap.yml index 2d20878f..4eaae12e 100644 --- a/_appmap/test/data/pytest/appmap.yml +++ b/_appmap/test/data/pytest/appmap.yml @@ -1,3 +1,5 @@ name: Simple +record_test_cases: true packages: - path: simple +- path: tests \ No newline at end of file diff --git a/_appmap/test/data/pytest/expected/pytest-numpy1-no-test-cases.appmap.json b/_appmap/test/data/pytest/expected/pytest-numpy1-no-test-cases.appmap.json new file mode 100644 index 00000000..bc711efa --- /dev/null +++ b/_appmap/test/data/pytest/expected/pytest-numpy1-no-test-cases.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": "tests/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": "{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: 'zero', 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": "{0: 'zero', 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/pytest-numpy1.appmap.json b/_appmap/test/data/pytest/expected/pytest-numpy1.appmap.json index cd3ef53c..6a90f1bc 100644 --- a/_appmap/test/data/pytest/expected/pytest-numpy1.appmap.json +++ b/_appmap/test/data/pytest/expected/pytest-numpy1.appmap.json @@ -8,95 +8,106 @@ "name": "appmap", "url": "https://github.com/applandinc/appmap-python" }, + "source_location": "tests/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": true, + "parameters": [], + "id": 1, + "event": "call", + "thread_id": 1, + "defined_class": "tests.test_simple", + "method_id": "test_hello_world", + "path": "tests/test_simple.py", + "lineno": 6 + }, + { "static": false, "receiver": { - "class": "simple.Simple", "kind": "req", + "value": "", "name": "self", - "value": "" + "class": "simple.Simple" }, "parameters": [], - "id": 1, + "id": 2, "event": "call", - "thread_id": 1 - }, - { + "thread_id": 1, "defined_class": "simple.Simple", - "method_id": "hello", + "method_id": "hello_world", "path": "simple.py", - "lineno": 2, + "lineno": 8 + }, + { "static": false, "receiver": { - "class": "simple.Simple", "kind": "req", + "value": "", "name": "self", - "value": "" + "class": "simple.Simple" }, "parameters": [], - "id": 2, + "id": 3, "event": "call", - "thread_id": 1 + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "hello", + "path": "simple.py", + "lineno": 2 }, { "return_value": { - "class": "builtins.str", - "value": "'Hello'" + "value": "'Hello'", + "class": "builtins.str" }, - "parent_id": 2, - "id": 3, + "parent_id": 3, + "id": 4, "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", - "value": "" + "class": "simple.Simple" }, "parameters": [], - "id": 4, + "id": 5, "event": "call", - "thread_id": 1 + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "world", + "path": "simple.py", + "lineno": 5 }, { "return_value": { - "class": "builtins.str", - "value": "'world!'" + "value": "'world!'", + "class": "builtins.str" }, - "parent_id": 4, - "id": 5, + "parent_id": 5, + "id": 6, "event": "return", "thread_id": 1 }, { "return_value": { - "class": "builtins.str", - "value": "'Hello world!'" + "value": "'Hello world!'", + "class": "builtins.str" }, - "parent_id": 1, - "id": 6, + "parent_id": 2, + "id": 7, "event": "return", "thread_id": 1 }, @@ -109,7 +120,7 @@ "class": "simple.Simple" }, "parameters": [], - "id": 7, + "id": 8, "event": "call", "thread_id": 1, "defined_class": "simple.Simple", @@ -144,7 +155,7 @@ "size": 2 } ], - "id": 8, + "id": 9, "event": "call", "thread_id": 1, "defined_class": "simple.Simple", @@ -168,8 +179,8 @@ ], "size": 2 }, - "parent_id": 8, - "id": 9, + "parent_id": 9, + "id": 10, "event": "return", "thread_id": 1 }, @@ -189,8 +200,18 @@ ], "size": 2 }, - "parent_id": 7, - "id": 10, + "parent_id": 8, + "id": 11, + "event": "return", + "thread_id": 1 + }, + { + "return_value": { + "value": "None", + "class": "builtins.NoneType" + }, + "parent_id": 1, + "id": 12, "event": "return", "thread_id": 1 } @@ -237,6 +258,24 @@ ] } ] + }, + { + "name": "tests", + "type": "package", + "children": [ + { + "name": "test_simple", + "type": "class", + "children": [ + { + "name": "test_hello_world", + "type": "function", + "location": "tests/test_simple.py:6", + "static": true + } + ] + } + ] } ] } \ No newline at end of file diff --git a/_appmap/test/data/pytest/expected/pytest-numpy2-no-test-cases.appmap.json b/_appmap/test/data/pytest/expected/pytest-numpy2-no-test-cases.appmap.json new file mode 100644 index 00000000..b6d96002 --- /dev/null +++ b/_appmap/test/data/pytest/expected/pytest-numpy2-no-test-cases.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": "tests/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/pytest-numpy2.appmap.json b/_appmap/test/data/pytest/expected/pytest-numpy2.appmap.json index 0f12e30c..8d6436b4 100644 --- a/_appmap/test/data/pytest/expected/pytest-numpy2.appmap.json +++ b/_appmap/test/data/pytest/expected/pytest-numpy2.appmap.json @@ -8,95 +8,106 @@ "name": "appmap", "url": "https://github.com/applandinc/appmap-python" }, + "source_location": "tests/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": true, + "parameters": [], + "id": 1, + "event": "call", + "thread_id": 1, + "defined_class": "tests.test_simple", + "method_id": "test_hello_world", + "path": "tests/test_simple.py", + "lineno": 6 + }, + { "static": false, "receiver": { - "class": "simple.Simple", "kind": "req", + "value": "", "name": "self", - "value": "" + "class": "simple.Simple" }, "parameters": [], - "id": 1, + "id": 2, "event": "call", - "thread_id": 1 - }, - { + "thread_id": 1, "defined_class": "simple.Simple", - "method_id": "hello", + "method_id": "hello_world", "path": "simple.py", - "lineno": 2, + "lineno": 8 + }, + { "static": false, "receiver": { - "class": "simple.Simple", "kind": "req", + "value": "", "name": "self", - "value": "" + "class": "simple.Simple" }, "parameters": [], - "id": 2, + "id": 3, "event": "call", - "thread_id": 1 + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "hello", + "path": "simple.py", + "lineno": 2 }, { "return_value": { - "class": "builtins.str", - "value": "'Hello'" + "value": "'Hello'", + "class": "builtins.str" }, - "parent_id": 2, - "id": 3, + "parent_id": 3, + "id": 4, "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", - "value": "" + "class": "simple.Simple" }, "parameters": [], - "id": 4, + "id": 5, "event": "call", - "thread_id": 1 + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "world", + "path": "simple.py", + "lineno": 5 }, { "return_value": { - "class": "builtins.str", - "value": "'world!'" + "value": "'world!'", + "class": "builtins.str" }, - "parent_id": 4, - "id": 5, + "parent_id": 5, + "id": 6, "event": "return", "thread_id": 1 }, { "return_value": { - "class": "builtins.str", - "value": "'Hello world!'" + "value": "'Hello world!'", + "class": "builtins.str" }, - "parent_id": 1, - "id": 6, + "parent_id": 2, + "id": 7, "event": "return", "thread_id": 1 }, @@ -109,7 +120,7 @@ "class": "simple.Simple" }, "parameters": [], - "id": 7, + "id": 8, "event": "call", "thread_id": 1, "defined_class": "simple.Simple", @@ -144,7 +155,7 @@ "size": 2 } ], - "id": 8, + "id": 9, "event": "call", "thread_id": 1, "defined_class": "simple.Simple", @@ -168,8 +179,8 @@ ], "size": 2 }, - "parent_id": 8, - "id": 9, + "parent_id": 9, + "id": 10, "event": "return", "thread_id": 1 }, @@ -189,8 +200,18 @@ ], "size": 2 }, - "parent_id": 7, - "id": 10, + "parent_id": 8, + "id": 11, + "event": "return", + "thread_id": 1 + }, + { + "return_value": { + "value": "None", + "class": "builtins.NoneType" + }, + "parent_id": 1, + "id": 12, "event": "return", "thread_id": 1 } @@ -237,6 +258,24 @@ ] } ] + }, + { + "name": "tests", + "type": "package", + "children": [ + { + "name": "test_simple", + "type": "class", + "children": [ + { + "name": "test_hello_world", + "type": "function", + "location": "tests/test_simple.py:6", + "static": true + } + ] + } + ] } ] } \ 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 1c9d0f21..d2862df1 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:30" + "location": "tests/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 cca17c0d..27955766 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:16" + "location": "tests/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 56494885..6f26ad59 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:21" + "location": "tests/test_simple.py:21" }, "exception": { "class": "AssertionError", diff --git a/_appmap/test/data/pytest/tests/__init__.py b/_appmap/test/data/pytest/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/_appmap/test/data/pytest/test_noappmap.py b/_appmap/test/data/pytest/tests/test_noappmap.py similarity index 100% rename from _appmap/test/data/pytest/test_noappmap.py rename to _appmap/test/data/pytest/tests/test_noappmap.py diff --git a/_appmap/test/data/pytest/test_simple.py b/_appmap/test/data/pytest/tests/test_simple.py similarity index 100% rename from _appmap/test/data/pytest/test_simple.py rename to _appmap/test/data/pytest/tests/test_simple.py diff --git a/_appmap/test/data/trial/appmap-no-test-cases.yml b/_appmap/test/data/trial/appmap-no-test-cases.yml new file mode 100644 index 00000000..595717ee --- /dev/null +++ b/_appmap/test/data/trial/appmap-no-test-cases.yml @@ -0,0 +1,4 @@ +name: deferred +record_test_cases: "false" +packages: +- path: test diff --git a/_appmap/test/data/trial/appmap.yml b/_appmap/test/data/trial/appmap.yml index 8dcecd82..ffa9f3da 100644 --- a/_appmap/test/data/trial/appmap.yml +++ b/_appmap/test/data/trial/appmap.yml @@ -1,3 +1,4 @@ name: deferred +record_test_cases: "true" packages: - path: test diff --git a/_appmap/test/data/trial/expected/pytest-no-test-cases.appmap.json b/_appmap/test/data/trial/expected/pytest-no-test-cases.appmap.json new file mode 100644 index 00000000..3bf51b05 --- /dev/null +++ b/_appmap/test/data/trial/expected/pytest-no-test-cases.appmap.json @@ -0,0 +1,28 @@ +{ + "version": "1.9", + "metadata": { + "language": { + "name": "python" + }, + "client": { + "name": "appmap", + "url": "https://github.com/applandinc/appmap-python" + }, + "feature_group": "Deferred", + "recording": { + "defined_class": "test.test_deferred.TestDeferred", + "method_id": "test_hello_world" + }, + "source_location": "test/test_deferred.py:7", + "name": "Deferred hello world", + "feature": "Hello world", + "app": "deferred", + "recorder": { + "name": "pytest", + "type": "tests" + }, + "test_status": "succeeded" + }, + "events": [], + "classMap": [] +} \ No newline at end of file diff --git a/_appmap/test/data/unittest/appmap-no-test-cases.yml b/_appmap/test/data/unittest/appmap-no-test-cases.yml new file mode 100644 index 00000000..4e0eb415 --- /dev/null +++ b/_appmap/test/data/unittest/appmap-no-test-cases.yml @@ -0,0 +1,4 @@ +name: Simple +record_test_cases: false +packages: +- path: simple diff --git a/_appmap/test/data/unittest/appmap.yml b/_appmap/test/data/unittest/appmap.yml index 2d20878f..817f8cf9 100644 --- a/_appmap/test/data/unittest/appmap.yml +++ b/_appmap/test/data/unittest/appmap.yml @@ -1,3 +1,4 @@ name: Simple +record_test_cases: true packages: - path: simple diff --git a/_appmap/test/data/unittest/expected/unittest-no-test-cases.appmap.json b/_appmap/test/data/unittest/expected/unittest-no-test-cases.appmap.json new file mode 100644 index 00000000..2880ba33 --- /dev/null +++ b/_appmap/test/data/unittest/expected/unittest-no-test-cases.appmap.json @@ -0,0 +1,148 @@ +{ + "version": "1.9", + "metadata": { + "language": { + "name": "python" + }, + "client": { + "name": "appmap", + "url": "https://github.com/applandinc/appmap-python" + }, + "feature_group": "Unit test test", + "recording": { + "defined_class": "simple.test_simple.UnitTestTest", + "method_id": "test_hello_world" + }, + "source_location": "simple/test_simple.py:14", + "name": "Unit test test hello world", + "feature": "Hello world", + "app": "Simple", + "recorder": { + "name": "unittest", + "type": "tests" + }, + "test_status": "succeeded" + }, + "events": [ + { + "static": false, + "receiver": { + "kind": "req", + "value": "", + "name": "self", + "class": "simple.Simple" + }, + "parameters": [ + { + "kind": "req", + "value": "'!'", + "name": "bang", + "class": "builtins.str" + } + ], + "id": 1, + "event": "call", + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "hello_world", + "path": "simple/__init__.py", + "lineno": 8 + }, + { + "static": false, + "receiver": { + "kind": "req", + "value": "", + "name": "self", + "class": "simple.Simple" + }, + "parameters": [], + "id": 2, + "event": "call", + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "hello", + "path": "simple/__init__.py", + "lineno": 2 + }, + { + "return_value": { + "value": "'Hello'", + "class": "builtins.str" + }, + "parent_id": 2, + "id": 3, + "event": "return", + "thread_id": 1 + }, + { + "static": false, + "receiver": { + "kind": "req", + "value": "", + "name": "self", + "class": "simple.Simple" + }, + "parameters": [], + "id": 4, + "event": "call", + "thread_id": 1, + "defined_class": "simple.Simple", + "method_id": "world", + "path": "simple/__init__.py", + "lineno": 5 + }, + { + "return_value": { + "value": "'world'", + "class": "builtins.str" + }, + "parent_id": 4, + "id": 5, + "event": "return", + "thread_id": 1 + }, + { + "return_value": { + "value": "'Hello world!'", + "class": "builtins.str" + }, + "parent_id": 1, + "id": 6, + "event": "return", + "thread_id": 1 + } + ], + "classMap": [ + { + "name": "simple", + "type": "package", + "children": [ + { + "name": "Simple", + "type": "class", + "children": [ + { + "name": "hello", + "type": "function", + "location": "simple/__init__.py:2", + "static": false + }, + { + "name": "hello_world", + "type": "function", + "location": "simple/__init__.py:8", + "static": false + }, + { + "name": "world", + "type": "function", + "location": "simple/__init__.py:5", + "static": false + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/_appmap/test/test_configuration.py b/_appmap/test/test_configuration.py index d5e2ed7a..a1492e4a 100644 --- a/_appmap/test/test_configuration.py +++ b/_appmap/test/test_configuration.py @@ -139,8 +139,10 @@ def test_empty_path(self, data_dir, caplog): class DefaultHelpers: def check_default_packages(self, actual_packages): + # Project directory has a "test" subdirectory, so actual_packages may have it (indicating a + # bug in the way directories are excluded). pkgs = [p["path"] for p in actual_packages if p["path"] in ("package", "test")] - assert ["package", "test"] == sorted(pkgs) + assert ["package"] == sorted(pkgs) def check_default_config(self, expected_name): assert appmap.enabled() @@ -149,6 +151,7 @@ def check_default_config(self, expected_name): assert default_config.name == expected_name self.check_default_packages(default_config.packages) assert default_config.default["appmap_dir"] == "tmp/appmap" + assert default_config.default["record_test_cases"] is False class TestDefaultConfig(DefaultHelpers): @@ -249,7 +252,7 @@ def test_empty(self, tmpdir): def test_missing_name(self, tmpdir): with self.incomplete_config() as f: - print('packages: [{"path": "package"}, {"path": "test"}]', file=f) + print('packages: [{"path": "package"}]', file=f) _appmap.initialize( cwd=tmpdir, env={"APPMAP_CONFIG": "appmap-incomplete.yml"}, diff --git a/_appmap/test/test_events.py b/_appmap/test/test_events.py index f57e0218..fba942fd 100644 --- a/_appmap/test/test_events.py +++ b/_appmap/test/test_events.py @@ -149,4 +149,3 @@ def check_call_return_stack_order(events): return True return False - diff --git a/_appmap/test/test_fastapi.py b/_appmap/test/test_fastapi.py index 7122508b..9a39eb93 100644 --- a/_appmap/test/test_fastapi.py +++ b/_appmap/test/test_fastapi.py @@ -24,11 +24,9 @@ class TestRecordRequests(_TestRecordRequests): @pytest.mark.app(remote_enabled=True) class TestRemoteRecording(_TestRemoteRecording): - def __init__(self): - self.expected_thread_id = None - self.expected_content_type = None - def setup_method(self): + # Can't add __init__, pytest won't collect test classes that have one + # pylint: disable=attribute-defined-outside-init self.expected_thread_id = 1 self.expected_content_type = "application/json" diff --git a/_appmap/test/test_test_frameworks.py b/_appmap/test/test_test_frameworks.py index b847c1e3..49467a95 100644 --- a/_appmap/test/test_test_frameworks.py +++ b/_appmap/test/test_test_frameworks.py @@ -67,6 +67,14 @@ def test_enabled(self, testdir): verify_expected_appmap(testdir) verify_expected_metadata(testdir) + def test_enabled_no_test_cases(self, testdir, monkeypatch): + monkeypatch.setenv("APPMAP_CONFIG", "appmap-no-test-cases.yml") + + self.run_tests(testdir) + + assert len(list(testdir.output().iterdir())) == 7 + verify_expected_appmap(testdir, "-no-test-cases") + verify_expected_metadata(testdir) class TestPytestRunnerUnittest(_TestTestRunner): @classmethod @@ -105,6 +113,16 @@ def test_enabled(self, testdir): verify_expected_appmap(testdir, f"-numpy{numpy_version.major}") verify_expected_metadata(testdir) + def test_enabled_no_test_cases(self, testdir, monkeypatch): + monkeypatch.setenv("APPMAP_CONFIG", "appmap-no-test-cases.yml") + + self.run_tests(testdir) + assert len(list(testdir.output().iterdir())) == 6 + numpy_version = package_version("numpy") + verify_expected_appmap(testdir, f"-numpy{numpy_version.major}-no-test-cases") + verify_expected_metadata(testdir) + + @pytest.mark.example_dir("trial") class TestPytestRunnerTrial(_TestTestRunner): @classmethod @@ -122,10 +140,15 @@ def run_tests(self, testdir): # unclean. result.assert_outcomes(xfailed=1) - def test_pytest_trial(self, testdir): + def test_enabled(self, testdir): self.run_tests(testdir) verify_expected_appmap(testdir) + def test_enabled_no_test_cases(self, testdir, monkeypatch): + monkeypatch.setenv("APPMAP_CONFIG", "appmap-no-test-cases.yml") + self.run_tests(testdir) + verify_expected_appmap(testdir, "-no-test-cases") + EMPTY_APPMAP = types.SimpleNamespace(events=[]) diff --git a/_appmap/testing_framework.py b/_appmap/testing_framework.py index eeffe45c..9a676be1 100644 --- a/_appmap/testing_framework.py +++ b/_appmap/testing_framework.py @@ -8,7 +8,8 @@ import inflection -from _appmap import configuration, env, recording +from _appmap import env, recording +from _appmap.configuration import Config from _appmap.recording import Recording from _appmap.utils import fqname, root_relative_path @@ -104,15 +105,13 @@ def record(self, klass, method, **kwds): item = FuncItem(klass, method, **kwds) metadata = item.metadata - metadata.update( - { - "app": configuration.Config.current.name, - "recorder": { - "name": self.name, - "type": self.recorder_type, - }, - } - ) + metadata.update({ + "app": Config.current.name, + "recorder": { + "name": self.name, + "type": self.recorder_type, + }, + }) rec = Recording() environ = env.Env.current @@ -174,3 +173,9 @@ def failure_location(exn: Exception) -> str: if relative: break return loc + + +def disable_test_case(fn): + record_test_cases = Config.current.record_test_cases + if not record_test_cases and hasattr(fn, "_self_enabled"): # it's instrumented + fn._self_enabled = False # pylint: disable=protected-access diff --git a/_appmap/unittest.py b/_appmap/unittest.py index d2a2bd7f..eb79c9a0 100644 --- a/_appmap/unittest.py +++ b/_appmap/unittest.py @@ -1,7 +1,3 @@ -import sys -import unittest -from contextlib import contextmanager - from _appmap import noappmap, testing_framework, wrapt from _appmap.env import Env from _appmap.utils import get_function_location @@ -13,72 +9,31 @@ def _get_test_location(cls, method_name): fn = getattr(cls, method_name) return get_function_location(fn) - -if sys.version_info[1] < 8: - # Prior to 3.8, unittest called the test case's test method directly, which left us without an - # opportunity to hook it. So, instead, instrument unittest.case._Outcome.testPartExecutor, a - # method used to run test cases. `isTest` will be True when the part is the actual test method, - # False when it's setUp or teardown. - @wrapt.patch_function_wrapper("unittest.case", "_Outcome.testPartExecutor") - @contextmanager - def testPartExecutor(wrapped, _, args, kwargs): - def _args(test_case, *_, isTest=False, **__): - return (test_case, isTest) - - test_case, is_test = _args(*args, **kwargs) - already_recording = getattr(test_case, "_appmap_pytest_recording", None) - # fmt: off - if ( - (not is_test) - or isinstance(test_case, unittest.case._SubTest) # pylint: disable=protected-access - or already_recording - ): - # fmt: on - with wrapped(*args, **kwargs): - yield - return - - method_name = test_case.id().split(".")[-1] - location = _get_test_location(test_case.__class__, method_name) - with _session.record( - test_case.__class__, method_name, location=location - ) as metadata: - if metadata: - with wrapped( - *args, **kwargs - ), testing_framework.collect_result_metadata(metadata): - yield - else: - # session.record may return None - yield - -else: - # We need to disable request recording in TestCase._callSetUp too - # in order to prevent creation of a request recording besides test - # recording when requests are made inside setUp method. - # This edge case can be observed in this test in django project: - # $ APPMAP=TRUE ./runtests.py auth_tests.test_views.ChangelistTests.test_user_change_email - #  (ChangelistTests.setUp makes a request) - @wrapt.patch_function_wrapper("unittest.case", "TestCase._callSetUp") - def callSetUp(wrapped, test_case, args, kwargs): # pylint: disable=unused-argument - with Env.current.disabled("requests"): - wrapped(*args, **kwargs) - - # As of 3.8, unittest.case.TestCase now calls the test's method indirectly, through - # TestCase._callTestMethod. Hook that to manage a recording session. - @wrapt.patch_function_wrapper("unittest.case", "TestCase._callTestMethod") - def callTestMethod(wrapped, test_case, args, kwargs): - already_recording = getattr(test_case, "_appmap_pytest_recording", None) - - test_method_name = test_case._testMethodName # pylint: disable=protected-access - test_method = getattr(test_case, test_method_name) - if already_recording or noappmap.disables(test_method, test_case.__class__): - wrapped(*args, **kwargs) - return - - method_name = test_case.id().split(".")[-1] - location = _get_test_location(test_case.__class__, method_name) - with _session.record(test_case.__class__, method_name, location=location) as metadata: - if metadata: - with testing_framework.collect_result_metadata(metadata): - wrapped(*args, **kwargs) +# We need to disable request recording in TestCase._callSetUp. This prevents creation of a request +# recording calls when requests made inside setUp method. +# +# This edge case can be observed in this test in django project: +# $ APPMAP=TRUE ./runtests.py auth_tests.test_views.ChangelistTests.test_user_change_email +# (ChangelistTests.setUp makes a request) +@wrapt.patch_function_wrapper("unittest.case", "TestCase._callSetUp") +def callSetUp(wrapped, _, args, kwargs): + with Env.current.disabled("requests"): + wrapped(*args, **kwargs) + +@wrapt.patch_function_wrapper("unittest.case", "TestCase._callTestMethod") +def callTestMethod(wrapped, test_case, _, kwargs): + already_recording = getattr(test_case, "_appmap_pytest_recording", None) + + test_method_name = test_case._testMethodName # pylint: disable=protected-access + test_method = getattr(test_case, test_method_name) + if already_recording or noappmap.disables(test_method, test_case.__class__): + wrapped(test_method, **kwargs) + return + + method_name = test_case.id().split(".")[-1] + location = _get_test_location(test_case.__class__, method_name) + testing_framework.disable_test_case(test_method) + with _session.record(test_case.__class__, method_name, location=location) as metadata: + if metadata: + with testing_framework.collect_result_metadata(metadata): + wrapped(test_method, **kwargs) diff --git a/appmap/pytest.py b/appmap/pytest.py index 8c555b52..67d3cb4d 100644 --- a/appmap/pytest.py +++ b/appmap/pytest.py @@ -58,6 +58,7 @@ def pytest_runtest_call(item): True, ) if not noappmap.disables(item.obj, item.cls): + testing_framework.disable_test_case(item.obj) item.obj = recorded_testcase(item)(item.obj) @pytest.hookimpl(hookwrapper=True) @@ -76,6 +77,7 @@ def pytest_pyfunc_call(pyfuncitem): method_id=pyfuncitem.originalname, location=pyfuncitem.location, ) as metadata: + testing_framework.disable_test_case(pyfuncitem.obj) result = yield try: with testing_framework.collect_result_metadata(metadata): diff --git a/vendor/_appmap/wrapt/wrappers.py b/vendor/_appmap/wrapt/wrappers.py index bbe9b0e5..31739da0 100644 --- a/vendor/_appmap/wrapt/wrappers.py +++ b/vendor/_appmap/wrapt/wrappers.py @@ -509,22 +509,25 @@ def _unpack_self(self, *args): return self.__wrapped__(*_args, **_kwargs) class _FunctionWrapperBase(ObjectProxy): - - __slots__ = ('_self_instance', '_self_wrapper', '_self_enabled', - '_self_binding', '_self_parent', '_bfws') - - def __init__(self, wrapped, instance, wrapper, enabled=None, - binding='function', parent=None): - + __slots__ = ( + "_self_instance", + "_self_wrapper", + "_self_enabled", + "_self_binding", + "_self_parent", + "_bfws", "_appmap_instrumented", + ) + + def __init__(self, wrapped, instance, wrapper, enabled=None, binding="function", parent=None): super(_FunctionWrapperBase, self).__init__(wrapped) - object.__setattr__(self, '_self_instance', instance) - object.__setattr__(self, '_self_wrapper', wrapper) - object.__setattr__(self, '_self_enabled', enabled) - object.__setattr__(self, '_self_binding', binding) - object.__setattr__(self, '_self_parent', parent) - object.__setattr__(self, '_bfws', list()) + object.__setattr__(self, "_self_instance", instance) + object.__setattr__(self, "_self_wrapper", wrapper) + object.__setattr__(self, "_self_enabled", enabled) + object.__setattr__(self, "_self_binding", binding) + object.__setattr__(self, "_self_parent", parent) + object.__setattr__(self, "_bfws", list()) object.__setattr__(self, "_appmap_instrumented", False) def __get__(self, instance, owner):