From feec761fefd5596c4fd7bde0cd9c3901e02791b3 Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Mon, 15 Jul 2024 15:45:13 -0400 Subject: [PATCH 1/6] fix: show config packages on startup In addition to the path of config file, show the packages property, too. --- _appmap/configuration.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/_appmap/configuration.py b/_appmap/configuration.py index e83fd0ca..af954dea 100644 --- a/_appmap/configuration.py +++ b/_appmap/configuration.py @@ -5,6 +5,7 @@ import ast import importlib.metadata import inspect +import json import os import sys from os.path import realpath @@ -144,6 +145,9 @@ def __init__(self): if "labels" in self._config: self.labels.append(self._config["labels"]) + def __repr__(self): + return json.dumps(self._config["packages"]) + @property def name(self): return self._config["name"] @@ -483,6 +487,7 @@ def initialize(): # pylint: disable=protected-access c._load_config(show_warnings=True) logger.info("file: %s", c._file if c.file_present else "[no appmap.yml]") + logger.info("config: %r", c) logger.debug("package_functions: %s", c.package_functions) logger.info("env: %r", os.environ) os.environ["_APPMAP_MESSAGES_SHOWN"] = "true" From 11b6307cf2bdbfae50f30d4f329e6ba3ac6f4035 Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Mon, 15 Jul 2024 15:47:29 -0400 Subject: [PATCH 2/6] fix: add APPMAP_INSTRUMENT_PROPERTIES Add APPMAP_INSTRUMENT_PROPERTIES to control whether properties should be instrumented. --- _appmap/importer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/_appmap/importer.py b/_appmap/importer.py index aca46a45..e077dda5 100644 --- a/_appmap/importer.py +++ b/_appmap/importer.py @@ -132,7 +132,7 @@ def is_member_func(m): if key.startswith("__"): continue static_value = inspect.getattr_static(cls, key) - if isinstance(static_value, property): + if Importer.instrument_properties and isinstance(static_value, property): properties[key] = ( static_value, { @@ -164,6 +164,9 @@ def initialize(cls): cls.filter_stack = [] cls.filter_chain = [] cls._skip_instrumenting = ("appmap", "_appmap") + cls.instrument_properties = ( + Env.current.get("APPMAP_INSTRUMENT_PROPERTIES", "true").lower() == "true" + ) @classmethod def use_filter(cls, filter_class): From 5cce0f0644eebf7d19bad5cda61726393cd7ba68 Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Fri, 19 Jul 2024 05:05:48 -0400 Subject: [PATCH 3/6] fix: improve property handling Base the decision to instrument a property on the property name, rather than trying to use the name of the f{get,set,del} functions. This aligns them with the rest of the class's members, e.g. for exclusion. Also, ensure that those functions only get instrumented once. --- _appmap/configuration.py | 4 ++-- _appmap/importer.py | 27 ++++++++++++++++++--------- _appmap/instrument.py | 2 +- _appmap/test/test_params.py | 2 +- appmap/fastapi.py | 2 +- vendor/_appmap/wrapt/wrappers.py | 2 ++ 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/_appmap/configuration.py b/_appmap/configuration.py index af954dea..870c2aec 100644 --- a/_appmap/configuration.py +++ b/_appmap/configuration.py @@ -423,8 +423,8 @@ def wrap(self, filterable): # appropriate. # rule = self.match(filterable) - wrapped = getattr(filterable.obj, "_appmap_wrapped", None) - if wrapped is None: + wrapped = getattr(filterable.obj, "_appmap_instrumented", None) + if not wrapped: logger.trace(" wrapping %s", filterable.fqname) Config.current.labels.apply(filterable) ret = instrument(filterable) diff --git a/_appmap/importer.py b/_appmap/importer.py index e077dda5..b188539b 100644 --- a/_appmap/importer.py +++ b/_appmap/importer.py @@ -42,8 +42,8 @@ class FilterableFn( ): __slots__ = () - def __new__(cls, scope, fn, static_fn, auxtype=None): - fqname = "%s.%s" % (scope.fqname, fn.__name__) + def __new__(cls, scope, fn_name, fn, static_fn, auxtype=None): # pylint: disable=too-many-arguments + fqname = "%s.%s" % (scope.fqname, fn_name) self = super(FilterableFn, cls).__new__(cls, scope.scope, fqname, fn, static_fn, auxtype) return self @@ -132,7 +132,9 @@ def is_member_func(m): if key.startswith("__"): continue static_value = inspect.getattr_static(cls, key) - if Importer.instrument_properties and isinstance(static_value, property): + # Don't use isinstance to check the type of static_value -- we don't want to invoke the + # descriptor protocol. + if Importer.instrument_properties and type(static_value) is property: # pylint: disable=unidiomatic-typecheck properties[key] = ( static_value, { @@ -194,27 +196,34 @@ def do_import(cls, *args, **kwargs): cls.filter_chain = reduce(lambda acc, e: e(acc), cls.filter_stack, NullFilter(None)) def instrument_functions(filterable, selected_functions=None): + # pylint: disable=too-many-locals logger.trace(" looking for members of %s", filterable.obj) functions, properties = get_members(filterable.obj) logger.trace(" functions %s", functions) for fn_name, static_fn, fn in functions: - filterableFn = FilterableFn(filterable, fn, static_fn) + filterableFn = FilterableFn(filterable, fn.__name__, fn, static_fn) new_fn = cls.instrument_function(fn_name, filterableFn, selected_functions) if new_fn != fn: - wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn) + fw = wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn) + fw._appmap_instrumented = True # pylint: disable=protected-access + # 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 or getattr(fn, "_appmap_wrapped", None): + if fn is None: + continue + filterableFn = FilterableFn(filterable, prop_name, fn, fn, auxtype) + if getattr(fn, "_appmap_instrumented", None): continue - filterableFn = FilterableFn(filterable, fn, fn, auxtype) - new_fn = cls.instrument_function(fn.__name__, filterableFn, selected_functions) + new_fn = cls.instrument_function(prop_name, filterableFn, selected_functions) if new_fn != fn: new_fn = wrapt.FunctionWrapper(fn, new_fn) - setattr(new_fn, "_appmap_wrapped", True) + # Set _appmap_instrumented on the FunctionWrapper, not on the wrapped function + new_fn._appmap_instrumented = True # pylint: disable=protected-access + instrumented_fns[k] = new_fn if len(instrumented_fns) > 0: instrumented_fns["doc"] = prop.__doc__ diff --git a/_appmap/instrument.py b/_appmap/instrument.py index 0fb2a9e7..79085372 100644 --- a/_appmap/instrument.py +++ b/_appmap/instrument.py @@ -138,5 +138,5 @@ def instrumented_fn(wrapped, instance, args, kwargs): return call_instrumented(f, instance, args, kwargs) ret = instrumented_fn - setattr(ret, "_appmap_wrapped", True) + setattr(ret, "_appmap_instrumented", True) return ret diff --git a/_appmap/test/test_params.py b/_appmap/test/test_params.py index d49d9a4d..3c079d55 100644 --- a/_appmap/test/test_params.py +++ b/_appmap/test/test_params.py @@ -44,7 +44,7 @@ def wrap_test_func(self, fnname): static_fn = inspect.getattr_static(C, fnname) fn = getattr(C, fnname) fc = FilterableCls(C) - ffn = FilterableFn(fc, fn, static_fn) + ffn = FilterableFn(fc, fn.__name__, fn, static_fn) wrapped = self.prepare(ffn) wrapt.wrap_function_wrapper(C, fnname, wrapped) diff --git a/appmap/fastapi.py b/appmap/fastapi.py index bfedba6a..da8acc44 100644 --- a/appmap/fastapi.py +++ b/appmap/fastapi.py @@ -34,7 +34,7 @@ def _add_api_route(wrapped, _, args, kwargs): fqn = utils.FqFnName(fn) scope = Filterable(fqn.scope, fqn.fqclass, None) - filterable_fn = FilterableFn(scope, fn, fn) + filterable_fn = FilterableFn(scope, fn.__name__, fn, fn) logger.debug("_add_api_route, fn: %s", filterable_fn.fqname) instrumented_fn = Importer.instrument_function(fqn.fn_name, filterable_fn) diff --git a/vendor/_appmap/wrapt/wrappers.py b/vendor/_appmap/wrapt/wrappers.py index f269bbcb..bbe9b0e5 100644 --- a/vendor/_appmap/wrapt/wrappers.py +++ b/vendor/_appmap/wrapt/wrappers.py @@ -516,6 +516,7 @@ class _FunctionWrapperBase(ObjectProxy): def __init__(self, wrapped, instance, wrapper, enabled=None, binding='function', parent=None): + "_appmap_instrumented", super(_FunctionWrapperBase, self).__init__(wrapped) object.__setattr__(self, '_self_instance', instance) @@ -524,6 +525,7 @@ def __init__(self, wrapped, instance, wrapper, enabled=None, 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): # This method is actually doing double duty for both unbound and From 1847b0e7177327adc080854fbbec17b89166d516 Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Fri, 19 Jul 2024 05:29:36 -0400 Subject: [PATCH 4/6] fix: try to avoid recording tests When creating the default config, ignore directories with names that match the regex .*test.*. This should avoid instrumenting the majority of test functions. For those that do still get instrumented, have the test framework integration disable the wrapt wrapped on it, thereby disabling recording. --- _appmap/configuration.py | 47 +++- .../test/data/pytest/appmap-no-test-cases.yml | 4 + _appmap/test/data/pytest/appmap.yml | 2 + .../pytest-numpy1-no-test-cases.appmap.json | 242 ++++++++++++++++++ .../pytest/expected/pytest-numpy1.appmap.json | 129 ++++++---- .../pytest-numpy2-no-test-cases.appmap.json | 242 ++++++++++++++++++ .../pytest/expected/pytest-numpy2.appmap.json | 129 ++++++---- .../expected/status_errored.metadata.json | 2 +- .../expected/status_failed.metadata.json | 2 +- .../expected/status_xfailed.metadata.json | 2 +- _appmap/test/data/pytest/tests/__init__.py | 0 .../data/pytest/{ => tests}/test_noappmap.py | 0 .../data/pytest/{ => tests}/test_simple.py | 0 .../test/data/trial/appmap-no-test-cases.yml | 4 + _appmap/test/data/trial/appmap.yml | 1 + .../expected/pytest-no-test-cases.appmap.json | 28 ++ .../data/unittest/appmap-no-test-cases.yml | 4 + _appmap/test/data/unittest/appmap.yml | 1 + .../unittest-no-test-cases.appmap.json | 148 +++++++++++ _appmap/test/test_configuration.py | 7 +- _appmap/test/test_events.py | 1 - _appmap/test/test_fastapi.py | 6 +- _appmap/test/test_test_frameworks.py | 25 +- _appmap/testing_framework.py | 25 +- _appmap/unittest.py | 101 ++------ appmap/pytest.py | 2 + vendor/_appmap/wrapt/wrappers.py | 29 ++- 27 files changed, 972 insertions(+), 211 deletions(-) create mode 100644 _appmap/test/data/pytest/appmap-no-test-cases.yml create mode 100644 _appmap/test/data/pytest/expected/pytest-numpy1-no-test-cases.appmap.json create mode 100644 _appmap/test/data/pytest/expected/pytest-numpy2-no-test-cases.appmap.json create mode 100644 _appmap/test/data/pytest/tests/__init__.py rename _appmap/test/data/pytest/{ => tests}/test_noappmap.py (100%) rename _appmap/test/data/pytest/{ => tests}/test_simple.py (100%) create mode 100644 _appmap/test/data/trial/appmap-no-test-cases.yml create mode 100644 _appmap/test/data/trial/expected/pytest-no-test-cases.appmap.json create mode 100644 _appmap/test/data/unittest/appmap-no-test-cases.yml create mode 100644 _appmap/test/data/unittest/expected/unittest-no-test-cases.appmap.json diff --git a/_appmap/configuration.py b/_appmap/configuration.py index 870c2aec..ed1965e3 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,19 @@ 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. For example, in a directory like this @@ -71,7 +72,7 @@ def find_top_packages(rootdir): LICENSE Makefile appveyor.yml docs/ src/ tests/ MANIFEST.in README.rst blog/ setup.py tddium.yml tox.ini - docs, src, tests, and blog will get scanned. + docs, src, blog will get scanned. tests will be ignored. Only src has a subdirectory containing an __init__.py: @@ -105,7 +106,7 @@ def find_top_packages(rootdir): packages = set() def excluded(d): - excluded = d == "node_modules" or d[0] == "." + excluded = _EXCLUDE_PATTERN.search(d) is not None if excluded: logger.trace("excluding dir %s", d) return excluded @@ -130,6 +131,19 @@ def excluded(d): 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 +170,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 +252,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 +353,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 +395,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): From ac94204d9bd35bf238865a7bf44cea039f8282fb Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Fri, 19 Jul 2024 05:33:01 -0400 Subject: [PATCH 5/6] fix: add ruff Add ruff to dev dependencies, along with an example config. At some point in the future, we may want to switch to using it, rather than pylint. --- .gitignore | 2 +- pyproject.toml | 1 + ruff.toml.example | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 ruff.toml.example diff --git a/.gitignore b/.gitignore index 274346f8..c6f0f746 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ htmlcov/ /.tox /node_modules - +/ruff.toml \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d2c18357..33a7be7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ pytest-env = "^1.1.3" pytest-console-scripts = "^1.4.1" pytest-xdist = "^3.6.1" psutil = "^6.0.0" +ruff = "^0.5.3" [build-system] requires = ["poetry-core>=1.1.0"] diff --git a/ruff.toml.example b/ruff.toml.example new file mode 100644 index 00000000..ef74a1df --- /dev/null +++ b/ruff.toml.example @@ -0,0 +1,5 @@ +line-length = 100 +extend-exclude = ["sitecustomize.py"] + +[lint.isort] +known-first-party = ['appmap', '_appmap'] \ No newline at end of file From 7538fa7ee811a32b398d492a5140ef180c80160b Mon Sep 17 00:00:00 2001 From: Alan Potter Date: Fri, 26 Jul 2024 13:43:14 -0400 Subject: [PATCH 6/6] test: pin incremental The latest update to incremental (which twisted depends on), appears to be broken. Pin to the previous version. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 33a7be7d..0c5dde22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ packaging = ">=19.0" [tool.poetry.group.dev.dependencies] Twisted = "^22.4.0" +incremental = "<24.7.0" asgiref = "^3.7.2" black = "^24.2.0" coverage = "^5.3"