Repository: getappmap/appmap-python (language: python)

Issue:
unittest tests can be flagged to not be recorded
We need annotations similar to the Ruby agent's `appmap: false` [Rspec annotation](https://appmap.io/docs/reference/appmap-ruby.html#disabling-appmaps-of-specific-rspec-tests).



Discussion / hints:

Fix patch:
diff --git a/_appmap/noappmap.py b/_appmap/noappmap.py
new file mode 100644
index 00000000..c20abfa0
--- /dev/null
+++ b/_appmap/noappmap.py
@@ -0,0 +1,14 @@
+import sys
+
+_appmap_noappmap = "_appmap_noappmap"
+
+
+def decorator(obj):
+    setattr(obj, _appmap_noappmap, True)
+    return obj
+
+
+def disables(test_fn, cls=None):
+    return hasattr(test_fn, _appmap_noappmap) or (
+        cls is not None and hasattr(cls, _appmap_noappmap)
+    )
diff --git a/appmap/__init__.py b/appmap/__init__.py
index 7edaf584..a2a83a47 100644
--- a/appmap/__init__.py
+++ b/appmap/__init__.py
@@ -3,6 +3,7 @@
 from _appmap.env import Env  # noqa: F401
 from _appmap.importer import instrument_module  # noqa: F401
 from _appmap.labels import labels  # noqa: F401
+from _appmap.noappmap import decorator as noappmap
 from _appmap.recording import Recording  # noqa: F401
 
 try:


Test patch:
diff --git a/_appmap/test/data/pytest/test_noappmap.py b/_appmap/test/data/pytest/test_noappmap.py
new file mode 100644
index 00000000..d8d5f5bb
--- /dev/null
+++ b/_appmap/test/data/pytest/test_noappmap.py
@@ -0,0 +1,18 @@
+from simple import Simple
+
+import appmap
+
+
+def test_recorded():
+    print(Simple().hello_world())
+
+
+@appmap.noappmap
+def test_unrecorded_fn():
+    print(Simple().hello_world())
+
+
+@appmap.noappmap
+class TestNotRecorded:
+    def test_unrecorded_method(self):
+        print(Simple().hello_world())
diff --git a/_appmap/test/data/unittest/expected/pytest.appmap.json b/_appmap/test/data/unittest/expected/pytest.appmap.json
index a3fa8b72..b389f4a9 100644
--- a/_appmap/test/data/unittest/expected/pytest.appmap.json
+++ b/_appmap/test/data/unittest/expected/pytest.appmap.json
@@ -12,7 +12,7 @@
     "recording": {
       "defined_class": "simple.test_simple.UnitTestTest",
       "method_id": "test_hello_world",
-      "source_location": "simple/test_simple.py:12"
+      "source_location": "simple/test_simple.py:13"
     },
     "name": "Unit test test hello world",
     "feature": "Hello world",
@@ -28,7 +28,7 @@
       "defined_class": "simple.test_simple.UnitTestTest",
       "method_id": "test_hello_world",
       "path": "simple/test_simple.py",
-      "lineno": 13,
+      "lineno": 14,
       "static": false,
       "receiver": {
         "name": "self",
@@ -178,7 +178,7 @@
                 {
                   "name": "test_hello_world",
                   "type": "function",
-                  "location": "simple/test_simple.py:13",
+                  "location": "simple/test_simple.py:14",
                   "static": false
                 }
               ]
diff --git a/_appmap/test/data/unittest/expected/status_errored.metadata.json b/_appmap/test/data/unittest/expected/status_errored.metadata.json
index f1e4a2e4..620f0f5f 100644
--- a/_appmap/test/data/unittest/expected/status_errored.metadata.json
+++ b/_appmap/test/data/unittest/expected/status_errored.metadata.json
@@ -2,7 +2,7 @@
   "test_status": "failed",
   "test_failure": {
     "message": "RuntimeError: test error",
-    "location": "simple/test_simple.py:34"
+    "location": "simple/test_simple.py:35"
   },
   "exception": {
     "class": "RuntimeError",
diff --git a/_appmap/test/data/unittest/expected/status_failed.metadata.json b/_appmap/test/data/unittest/expected/status_failed.metadata.json
index 93e98ae9..3f5ce685 100644
--- a/_appmap/test/data/unittest/expected/status_failed.metadata.json
+++ b/_appmap/test/data/unittest/expected/status_failed.metadata.json
@@ -2,7 +2,7 @@
   "test_status": "failed",
   "test_failure": {
     "message": "AssertionError: False is not true",
-    "location": "simple/test_simple.py:22"
+    "location": "simple/test_simple.py:23"
   },
   "exception": {
     "class": "AssertionError",
diff --git a/_appmap/test/data/unittest/expected/status_xfailed.metadata.json b/_appmap/test/data/unittest/expected/status_xfailed.metadata.json
index da5abda2..84c55bee 100644
--- a/_appmap/test/data/unittest/expected/status_xfailed.metadata.json
+++ b/_appmap/test/data/unittest/expected/status_xfailed.metadata.json
@@ -2,7 +2,7 @@
   "test_status": "failed",
   "test_failure": {
     "message": "AssertionError: False is not true",
-    "location": "simple/test_simple.py:26"
+    "location": "simple/test_simple.py:27"
   },
   "exception": {
     "class": "AssertionError",
diff --git a/_appmap/test/data/unittest/expected/unittest.appmap.json b/_appmap/test/data/unittest/expected/unittest.appmap.json
index f524fe0c..555a46b7 100644
--- a/_appmap/test/data/unittest/expected/unittest.appmap.json
+++ b/_appmap/test/data/unittest/expected/unittest.appmap.json
@@ -12,7 +12,7 @@
     "recording": {
       "defined_class": "simple.test_simple.UnitTestTest",
       "method_id": "test_hello_world",
-      "source_location": "simple/test_simple.py:13"
+      "source_location": "simple/test_simple.py:14"
     },
     "name": "Unit test test hello world",
     "feature": "Hello world",
@@ -28,7 +28,7 @@
       "defined_class": "simple.test_simple.UnitTestTest",
       "method_id": "test_hello_world",
       "path": "simple/test_simple.py",
-      "lineno": 13,
+      "lineno": 14,
       "static": false,
       "receiver": {
         "name": "self",
@@ -178,7 +178,7 @@
                 {
                   "name": "test_hello_world",
                   "type": "function",
-                  "location": "simple/test_simple.py:13",
+                  "location": "simple/test_simple.py:14",
                   "static": false
                 }
               ]
diff --git a/_appmap/test/data/unittest/simple/test_noappmap.py b/_appmap/test/data/unittest/simple/test_noappmap.py
new file mode 100644
index 00000000..8a4248a7
--- /dev/null
+++ b/_appmap/test/data/unittest/simple/test_noappmap.py
@@ -0,0 +1,11 @@
+import unittest
+
+import simple
+
+import appmap
+
+
+@appmap.noappmap
+class TestNoAppMap(unittest.TestCase):
+    def test_unrecorded(self):
+        print(simple.Simple().hello_world("!"))
diff --git a/_appmap/test/data/unittest/simple/test_simple.py b/_appmap/test/data/unittest/simple/test_simple.py
index 0c895645..5267ee78 100644
--- a/_appmap/test/data/unittest/simple/test_simple.py
+++ b/_appmap/test/data/unittest/simple/test_simple.py
@@ -7,6 +7,7 @@
 # finders correctly.
 from decouple import config
 
+import appmap
 
 
 class UnitTestTest(unittest.TestCase):
@@ -42,3 +43,7 @@ def tearDown(self):
     def test_with_subtest(self):
         with self.subTest("subtest"):
             self.assertEqual(simple.Simple().hello_world("!"), "Hello world!")
+
+    @appmap.noappmap
+    def test_unrecorded(self):
+        print(simple.Simple().hello_world("!"))
diff --git a/_appmap/test/test_test_frameworks.py b/_appmap/test/test_test_frameworks.py
index 52a5bf50..852859d3 100644
--- a/_appmap/test/test_test_frameworks.py
+++ b/_appmap/test/test_test_frameworks.py
@@ -66,7 +66,7 @@ def setup_class(cls):
     def run_tests(self, testdir):
         testdir.test_type = "pytest"
         result = testdir.runpytest("-svv")
-        result.assert_outcomes(passed=3, failed=3, xfailed=1)
+        result.assert_outcomes(passed=5, failed=3, xfailed=1)
 
     def test_enabled(self, testdir):
         self.run_tests(testdir)
@@ -86,11 +86,11 @@ def setup_class(cls):
 
     def run_tests(self, testdir):
         result = testdir.runpytest("-vv")
-        result.assert_outcomes(passed=1, failed=2, xpassed=1, xfailed=1)
+        result.assert_outcomes(passed=4, failed=2, xpassed=1, xfailed=1)
 
     def test_enabled(self, testdir):
         self.run_tests(testdir)
-        assert len(list(testdir.output().iterdir())) == 5
+        assert len(list(testdir.output().iterdir())) == 6
         verify_expected_appmap(testdir)
         verify_expected_metadata(testdir)
 
diff --git a/_appmap/unittest.py b/_appmap/unittest.py
index 93ca5184..c6f733d2 100644
--- a/_appmap/unittest.py
+++ b/_appmap/unittest.py
@@ -2,7 +2,7 @@
 import unittest
 from contextlib import contextmanager
 
-from _appmap import testing_framework, wrapt
+from _appmap import noappmap, testing_framework, wrapt
 from _appmap.utils import get_function_location
 
 _session = testing_framework.session("unittest", "tests")
@@ -57,7 +57,8 @@ def _args(test_case, *_, isTest=False, **__):
     @wrapt.patch_function_wrapper("unittest.case", "TestCase._callTestMethod")
     def callTestMethod(wrapped, test_case, args, kwargs):
         already_recording = getattr(test_case, "_appmap_pytest_recording", None)
-        if already_recording:
+        test_method = getattr(test_case, test_case._testMethodName)
+        if already_recording or noappmap.disables(test_method, test_case.__class__):
             wrapped(*args, **kwargs)
             return
 
diff --git a/appmap/pytest.py b/appmap/pytest.py
index 703e0e88..3856e056 100644
--- a/appmap/pytest.py
+++ b/appmap/pytest.py
@@ -1,6 +1,8 @@
+import sys
+
 import pytest
 
-from _appmap import testing_framework, wrapt
+from _appmap import noappmap, testing_framework, wrapt
 from _appmap.env import Env
 
 logger = Env.current.getLogger(__name__)
@@ -55,7 +57,8 @@ def pytest_runtest_call(item):
                 "_appmap_pytest_recording",
                 True,
             )
-            item.obj = recorded_testcase(item)(item.obj)
+            if not noappmap.disables(item.obj, item.cls):
+                item.obj = recorded_testcase(item)(item.obj)
 
     @pytest.hookimpl(hookwrapper=True)
     def pytest_pyfunc_call(pyfuncitem):
@@ -63,6 +66,10 @@ def pytest_pyfunc_call(pyfuncitem):
         # pytest test.
         assert not hasattr(pyfuncitem, "_testcase")
 
+        if noappmap.disables(pyfuncitem.function, pyfuncitem.cls):
+            yield
+            return
+
         with pyfuncitem.session.appmap.record(
             pyfuncitem.cls,
             pyfuncitem.name,