From c4d7d17b6bbdbc83749e0e92b5cfea9d3c6e0220 Mon Sep 17 00:00:00 2001 From: kgilpin Date: Tue, 31 Mar 2026 19:53:37 -0400 Subject: [PATCH 1/3] docs: Add CLAUDE.md with test running instructions Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..848fa393 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# appmap-python + +Python agent for AppMap. Records function calls, HTTP requests, SQL queries, parameters, return values, and exceptions into `.appmap.json` files. + +## Running tests + +Tests must be run via `tox` or the `appmap-python` wrapper, not bare `pytest`. The wrapper sets `APPMAP=true`, which is required for conditional imports in `appmap/__init__.py` (e.g. `generation`). Subprocess-based tests also need the `appmap-python` script in PATH. + +```sh +# Correct - via tox (how CI runs them) +tox + +# Correct - via appmap-python wrapper +appmap-python pytest + +# Also works for quick local iteration on non-subprocess tests +APPMAP=true .venv/bin/python -m pytest _appmap/test/test_events.py + +# WRONG - will fail on subprocess tests +pytest +``` + +## Project structure + +- `appmap/` - Public package entry point (conditional imports based on APPMAP env var) +- `_appmap/` - Internal implementation (event recording, instrumentation, web framework integration) +- `_appmap/test/` - Test suite +- `_appmap/test/data/` - Test fixtures and expected appmap JSON files From f5a6665b952a4fe3e9c2c452789048025c96ffce Mon Sep 17 00:00:00 2001 From: kgilpin Date: Wed, 1 Apr 2026 11:01:33 -0400 Subject: [PATCH 2/3] chore: Test that labeled functions are always recorded --- _appmap/test/test_labels.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/_appmap/test/test_labels.py b/_appmap/test/test_labels.py index a5a11887..8abcc9fb 100644 --- a/_appmap/test/test_labels.py +++ b/_appmap/test/test_labels.py @@ -1,5 +1,6 @@ import pytest +import appmap from _appmap.wrapt import BoundFunctionWrapper, FunctionWrapper @@ -55,6 +56,22 @@ def check_labels(*_): verify_example_appmap(check_labels, "instance_method") + @pytest.mark.appmap_enabled(config="appmap-no-pyyaml.yml") + def test_labeled_function_recorded_without_package(self): + """A labeled function is recorded even when its package is not in the config.""" + import yaml # pylint: disable=import-outside-toplevel + + rec = appmap.Recording() + with rec: + yaml.dump({"key": "value"}) + + # yaml.dump should appear in the recording events because it's labeled + # by the formats preset, even though PyYAML is not in the packages list. + call_events = [e for e in rec.events if e.event == "call"] + assert any( + e.method_id == "dump" and "yaml" in e.defined_class for e in call_events + ), f"Expected yaml.dump in recorded events, got: {[e.method_id for e in call_events]}" + def test_function_only_in_mod(self, verify_example_appmap): def check_labels(*_): # pylint: disable=import-outside-toplevel From 5ee53e74054b9872d3f59d769598f84762b340f5 Mon Sep 17 00:00:00 2001 From: kgilpin Date: Tue, 31 Mar 2026 19:43:28 -0400 Subject: [PATCH 3/3] feat!: Use raw string values instead of repr() for str types in display_string BREAKING CHANGE: String values in appmap events are now recorded verbatim (e.g. "hello") rather than as Python repr (e.g. "'hello'"). This affects parameters, return values, and HTTP message fields of type builtins.str. The class field already identifies the type, so repr-quoting was redundant. Using raw string values also enables proper secret leak detection, since recorded values now match what appears in log messages. Co-Authored-By: Claude Opus 4.6 (1M context) --- _appmap/event.py | 9 +++++---- _appmap/test/data/expected.appmap.json | 18 ++++++++--------- .../pytest-numpy1-no-test-cases.appmap.json | 8 ++++---- .../pytest/expected/pytest-numpy1.appmap.json | 8 ++++---- .../pytest-numpy2-no-test-cases.appmap.json | 8 ++++---- .../pytest/expected/pytest-numpy2.appmap.json | 8 ++++---- .../data/unittest/expected/pytest.appmap.json | 20 ++++++++++--------- .../unittest-no-test-cases.appmap.json | 10 +++++----- .../unittest/expected/unittest.appmap.json | 20 ++++++++++--------- _appmap/test/test_events.py | 6 +++--- _appmap/test/test_http.py | 4 ++-- _appmap/test/test_params.py | 8 ++++---- _appmap/test/web_framework.py | 16 +++++++-------- 13 files changed, 74 insertions(+), 69 deletions(-) diff --git a/_appmap/event.py b/_appmap/event.py index 4bdcf3af..9dc446ea 100644 --- a/_appmap/event.py +++ b/_appmap/event.py @@ -47,13 +47,14 @@ def reset(cls): def display_string(val, display_value=False): # If we're asked to display parameters, make a best-effort attempt - # to get a string value for the parameter using repr(). If parameter - # display is disabled, or repr() has raised, just formulate a value - # from the class and id. + # to get a string value for the parameter. str types are returned as-is; + # other types use repr(). If parameter display is disabled, or repr() has + # raised, just formulate a value from the class and id. value = None if display_value: try: - value = repr(val) + # Use issubclass(type()) instead of isinstance() to avoid side effects on lazy objects + value = val if issubclass(type(val), str) else repr(val) except Exception: # pylint: disable=broad-except pass diff --git a/_appmap/test/data/expected.appmap.json b/_appmap/test/data/expected.appmap.json index 311430be..9e691cb5 100644 --- a/_appmap/test/data/expected.appmap.json +++ b/_appmap/test/data/expected.appmap.json @@ -23,7 +23,7 @@ { "return_value": { "class": "builtins.str", - "value": "'ExampleClass.static_method\\n...\\n'" + "value": "ExampleClass.static_method\n...\n" }, "parent_id": 1, "id": 2, @@ -49,7 +49,7 @@ { "return_value": { "class": "builtins.str", - "value": "'ClassMethodMixin#class_method, cls ExampleClass'" + "value": "ClassMethodMixin#class_method, cls ExampleClass" }, "parent_id": 3, "id": 4, @@ -75,7 +75,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Super#instance_method'" + "value": "Super#instance_method" }, "parent_id": 5, "id": 6, @@ -127,7 +127,7 @@ "name": "data", "kind": "req", "class": "builtins.str", - "value": "'ExampleClass.call_yaml'" + "value": "ExampleClass.call_yaml" } ], "id": 10, @@ -144,7 +144,7 @@ "name": "data", "kind": "req", "class": "builtins.str", - "value": "'ExampleClass.call_yaml'" + "value": "ExampleClass.call_yaml" }, { "name": "stream", @@ -176,7 +176,7 @@ { "return_value": { "class": "builtins.str", - "value": "'ExampleClass.call_yaml\\n...\\n'" + "value": "ExampleClass.call_yaml\n...\n" }, "parent_id": 11, "id": 12, @@ -190,7 +190,7 @@ "name": "data", "kind": "req", "class": "builtins.str", - "value": "'ExampleClass.call_yaml'" + "value": "ExampleClass.call_yaml" }, { "name": "stream", @@ -222,7 +222,7 @@ { "return_value": { "class": "builtins.str", - "value": "'ExampleClass.call_yaml\\n...\\n'" + "value": "ExampleClass.call_yaml\n...\n" }, "parent_id": 13, "id": 14, @@ -334,4 +334,4 @@ ] } ] -} \ 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 index bc711efa..2ffc2d4c 100644 --- 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 @@ -56,7 +56,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Hello'" + "value": "Hello" }, "parent_id": 2, "id": 3, @@ -83,7 +83,7 @@ { "return_value": { "class": "builtins.str", - "value": "'world!'" + "value": "world!" }, "parent_id": 4, "id": 5, @@ -93,7 +93,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Hello world!'" + "value": "Hello world!" }, "parent_id": 1, "id": 6, @@ -239,4 +239,4 @@ ] } ] -} \ 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 6a90f1bc..b8e4f595 100644 --- a/_appmap/test/data/pytest/expected/pytest-numpy1.appmap.json +++ b/_appmap/test/data/pytest/expected/pytest-numpy1.appmap.json @@ -66,7 +66,7 @@ }, { "return_value": { - "value": "'Hello'", + "value": "Hello", "class": "builtins.str" }, "parent_id": 3, @@ -93,7 +93,7 @@ }, { "return_value": { - "value": "'world!'", + "value": "world!", "class": "builtins.str" }, "parent_id": 5, @@ -103,7 +103,7 @@ }, { "return_value": { - "value": "'Hello world!'", + "value": "Hello world!", "class": "builtins.str" }, "parent_id": 2, @@ -278,4 +278,4 @@ ] } ] -} \ 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 index b6d96002..c6c93ef4 100644 --- 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 @@ -56,7 +56,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Hello'" + "value": "Hello" }, "parent_id": 2, "id": 3, @@ -83,7 +83,7 @@ { "return_value": { "class": "builtins.str", - "value": "'world!'" + "value": "world!" }, "parent_id": 4, "id": 5, @@ -93,7 +93,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Hello world!'" + "value": "Hello world!" }, "parent_id": 1, "id": 6, @@ -239,4 +239,4 @@ ] } ] -} \ 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 8d6436b4..b4367584 100644 --- a/_appmap/test/data/pytest/expected/pytest-numpy2.appmap.json +++ b/_appmap/test/data/pytest/expected/pytest-numpy2.appmap.json @@ -66,7 +66,7 @@ }, { "return_value": { - "value": "'Hello'", + "value": "Hello", "class": "builtins.str" }, "parent_id": 3, @@ -93,7 +93,7 @@ }, { "return_value": { - "value": "'world!'", + "value": "world!", "class": "builtins.str" }, "parent_id": 5, @@ -103,7 +103,7 @@ }, { "return_value": { - "value": "'Hello world!'", + "value": "Hello world!", "class": "builtins.str" }, "parent_id": 2, @@ -278,4 +278,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/_appmap/test/data/unittest/expected/pytest.appmap.json b/_appmap/test/data/unittest/expected/pytest.appmap.json index 972cf161..57a69bb5 100644 --- a/_appmap/test/data/unittest/expected/pytest.appmap.json +++ b/_appmap/test/data/unittest/expected/pytest.appmap.json @@ -53,12 +53,14 @@ "class": "simple.Simple", "value": "" }, - "parameters": [{ - "class": "builtins.str", - "kind": "req", - "name": "bang", - "value": "'!'" - }], + "parameters": [ + { + "class": "builtins.str", + "kind": "req", + "name": "bang", + "value": "!" + } + ], "id": 2, "event": "call", "thread_id": 1 @@ -83,7 +85,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Hello'" + "value": "Hello" }, "parent_id": 3, "id": 4, @@ -110,7 +112,7 @@ { "return_value": { "class": "builtins.str", - "value": "'world'" + "value": "world" }, "parent_id": 5, "id": 6, @@ -120,7 +122,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Hello world!'" + "value": "Hello world!" }, "parent_id": 2, "id": 7, 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 index 2880ba33..f5f5e727 100644 --- a/_appmap/test/data/unittest/expected/unittest-no-test-cases.appmap.json +++ b/_appmap/test/data/unittest/expected/unittest-no-test-cases.appmap.json @@ -35,7 +35,7 @@ "parameters": [ { "kind": "req", - "value": "'!'", + "value": "!", "name": "bang", "class": "builtins.str" } @@ -67,7 +67,7 @@ }, { "return_value": { - "value": "'Hello'", + "value": "Hello", "class": "builtins.str" }, "parent_id": 2, @@ -94,7 +94,7 @@ }, { "return_value": { - "value": "'world'", + "value": "world", "class": "builtins.str" }, "parent_id": 4, @@ -104,7 +104,7 @@ }, { "return_value": { - "value": "'Hello world!'", + "value": "Hello world!", "class": "builtins.str" }, "parent_id": 1, @@ -145,4 +145,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/_appmap/test/data/unittest/expected/unittest.appmap.json b/_appmap/test/data/unittest/expected/unittest.appmap.json index f3fa7526..a4081904 100644 --- a/_appmap/test/data/unittest/expected/unittest.appmap.json +++ b/_appmap/test/data/unittest/expected/unittest.appmap.json @@ -53,12 +53,14 @@ "class": "simple.Simple", "value": "" }, - "parameters": [{ - "class": "builtins.str", - "kind": "req", - "name": "bang", - "value": "'!'" - }], + "parameters": [ + { + "class": "builtins.str", + "kind": "req", + "name": "bang", + "value": "!" + } + ], "id": 2, "event": "call", "thread_id": 1 @@ -83,7 +85,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Hello'" + "value": "Hello" }, "parent_id": 3, "id": 4, @@ -110,7 +112,7 @@ { "return_value": { "class": "builtins.str", - "value": "'world'" + "value": "world" }, "parent_id": 5, "id": 6, @@ -120,7 +122,7 @@ { "return_value": { "class": "builtins.str", - "value": "'Hello world!'" + "value": "Hello world!" }, "parent_id": 2, "id": 7, diff --git a/_appmap/test/test_events.py b/_appmap/test/test_events.py index cbab31e8..65142e68 100644 --- a/_appmap/test/test_events.py +++ b/_appmap/test/test_events.py @@ -132,11 +132,11 @@ def test_labeled_params_displayed_by_default(self): assert result == "hello" call_event = r.events[0] - # Parameter value should be the repr, not the opaque object string - assert call_event.parameters[0]["value"] == "'hello'" + # Parameter value should be the raw string, not repr-quoted + assert call_event.parameters[0]["value"] == "hello" # Return value should also be displayed return_event = r.events[1] - assert return_event.return_value["value"] == "'hello'" + assert return_event.return_value["value"] == "hello" # Unlabeled method should not have its params displayed, even in the same recording call_event_unlabeled = r.events[2] diff --git a/_appmap/test/test_http.py b/_appmap/test/test_http.py index b0cc048b..92ea305a 100644 --- a/_appmap/test/test_http.py +++ b/_appmap/test/test_http.py @@ -29,8 +29,8 @@ def test_http_client_capture(mock_requests, events): } message = request.message assert message[0] == DictIncluding({"name": "q", "value": "['one', 'two']"}) - assert (message[1] == DictIncluding({"name": "q2", "value": "'🦠'"})) or ( - message[1] == DictIncluding({"name": "q2", "value": "'\\U0001f9a0'"}) + assert (message[1] == DictIncluding({"name": "q2", "value": "🦠"})) or ( + message[1] == DictIncluding({"name": "q2", "value": "\\U0001f9a0"}) ) assert events[3].http_client_response == DictIncluding( diff --git a/_appmap/test/test_params.py b/_appmap/test/test_params.py index 7f6d3436..bf836dcd 100644 --- a/_appmap/test/test_params.py +++ b/_appmap/test/test_params.py @@ -108,7 +108,7 @@ def test_one_param(self, params): "name": "p", "class": "builtins.str", "kind": "req", - "value": "'static'", + "value": "static", } @@ -131,7 +131,7 @@ def test_one_param(self, params): self.assert_parameter( evt, 0, - {"name": "p", "class": "builtins.str", "kind": "req", "value": "'cls'"}, + {"name": "p", "class": "builtins.str", "kind": "req", "value": "cls"}, ) @@ -145,7 +145,7 @@ def test_no_args(self, params): @pytest.mark.parametrize( "params,arg,expected", [ - ("one", "world", ("builtins.str", "'world'")), + ("one", "world", ("builtins.str", "world")), ("one", None, ("builtins.NoneType", "None")), ], indirect=["params"], @@ -192,7 +192,7 @@ def test_one_receiver_none(self, params): @pytest.mark.parametrize( "params,arg,expected", [ - ("one", "world", ("builtins.str", "'world'")), + ("one", "world", ("builtins.str", "world")), ("one", None, ("builtins.NoneType", "None")), ], indirect=["params"], diff --git a/_appmap/test/web_framework.py b/_appmap/test/web_framework.py index 0729cace..425088ed 100644 --- a/_appmap/test/web_framework.py +++ b/_appmap/test/web_framework.py @@ -45,7 +45,7 @@ def test_post_bad_json(events, client, bad_json): ) assert events[0].message == [ - DictIncluding({"name": "my_param", "class": "builtins.str", "value": "'example'"}) + DictIncluding({"name": "my_param", "class": "builtins.str", "value": "example"}) ] @staticmethod @@ -53,7 +53,7 @@ def test_post_multipart(events, client): client.post("/test", data={"my_param": "example"}, content_type="multipart/form-data") assert events[0].message == [ - DictIncluding({"name": "my_param", "class": "builtins.str", "value": "'example'"}) + DictIncluding({"name": "my_param", "class": "builtins.str", "value": "example"}) ] @@ -119,7 +119,7 @@ def test_post(events, client): assert events[0].message == [ DictIncluding( - {"name": "my_param", "class": "builtins.str", "value": "'example'"} + {"name": "my_param", "class": "builtins.str", "value": "example"} ) ] assert events[0].http_server_request == DictIncluding( @@ -142,7 +142,7 @@ def test_get(events, client): assert events[0].message == [ DictIncluding( - {"name": "my_param", "class": "builtins.str", "value": "'example'"} + {"name": "my_param", "class": "builtins.str", "value": "example"} ) ] @@ -166,7 +166,7 @@ def test_put(events, client): assert events[0].message == [ DictIncluding( - {"name": "my_param", "class": "builtins.str", "value": "'example'"} + {"name": "my_param", "class": "builtins.str", "value": "example"} ) ] @@ -205,7 +205,7 @@ def test_message_path_segments(events, client): assert events[0].message == [ DictIncluding( - {"name": "username", "class": "builtins.str", "value": "'alice'"} + {"name": "username", "class": "builtins.str", "value": "alice"} ), DictIncluding({"name": "post_id", "class": "builtins.int", "value": "42"}), ] @@ -222,7 +222,7 @@ def test_post_form_urlencoded(events, client): ) assert events[0].message == [ - DictIncluding({"name": "my_param", "class": "builtins.str", "value": "'example'"}) + DictIncluding({"name": "my_param", "class": "builtins.str", "value": "example"}) ] @staticmethod @@ -230,7 +230,7 @@ def test_post_multipart(events, client): client.post("/test", data={"my_param": "example"}, content_type="multipart/form-data") assert events[0].message == [ - DictIncluding({"name": "my_param", "class": "builtins.str", "value": "'example'"}) + DictIncluding({"name": "my_param", "class": "builtins.str", "value": "example"}) ]