From f9945dc0f7ccb82045489396bb41e61bfc8d7294 Mon Sep 17 00:00:00 2001 From: kgilpin Date: Tue, 31 Mar 2026 19:43:28 -0400 Subject: [PATCH 1/4] fix: Use raw string values instead of repr() for str types in display_string When APPMAP_DISPLAY_PARAMS=true, repr() wraps string values in quotes (e.g. 'my-api-key'), which breaks the scanner's secret-in-log substring matching. By using the raw string value directly for str types, the recorded value matches what appears in log messages, enabling proper secret leak detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- _appmap/event.py | 2 +- _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, 70 insertions(+), 66 deletions(-) diff --git a/_appmap/event.py b/_appmap/event.py index ad8d20d0..901f0bc0 100644 --- a/_appmap/event.py +++ b/_appmap/event.py @@ -61,7 +61,7 @@ def display_string(val, has_labels=False): value = None if _should_display(has_labels): try: - value = repr(val) + value = val if isinstance(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 005033b0..de7aaeec 100644 --- a/_appmap/test/test_events.py +++ b/_appmap/test/test_events.py @@ -129,11 +129,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" @pytest.mark.appmap_enabled(env={"APPMAP_DISPLAY_PARAMS": "false", "APPMAP_DISPLAY_LABELED_PARAMS": "false"}) def test_labeled_params_not_displayed_when_disabled(self): 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 26669861..cfe37a17 100644 --- a/_appmap/test/test_params.py +++ b/_appmap/test/test_params.py @@ -106,7 +106,7 @@ def test_one_param(self, params): "name": "p", "class": "builtins.str", "kind": "req", - "value": "'static'", + "value": "static", } @@ -129,7 +129,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"}, ) @@ -143,7 +143,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"], @@ -190,7 +190,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"}) ] From 01592d816fb6fc39a5a7c5d3bfacefa4e09d2b95 Mon Sep 17 00:00:00 2001 From: kgilpin Date: Tue, 31 Mar 2026 19:53:37 -0400 Subject: [PATCH 2/4] 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 622ec72013a0bb88541bf56cb21e9765ec9b50f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:04:12 +0000 Subject: [PATCH 3/4] fix: use issubclass(type(val), str) and update display_string comment Agent-Logs-Url: https://github.com/getappmap/appmap-python/sessions/164d7ad2-ff95-401b-a976-40cbc36ab6a5 Co-authored-by: kgilpin <86395+kgilpin@users.noreply.github.com> --- _appmap/event.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/_appmap/event.py b/_appmap/event.py index 901f0bc0..6306d2c7 100644 --- a/_appmap/event.py +++ b/_appmap/event.py @@ -55,13 +55,13 @@ def _should_display(has_labels): def display_string(val, has_labels=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 _should_display(has_labels): try: - value = val if isinstance(val, str) else repr(val) + value = val if issubclass(type(val), str) else repr(val) except Exception: # pylint: disable=broad-except pass From eb77b1c9a154639183eba36d5ce04f3d688056ea Mon Sep 17 00:00:00 2001 From: kgilpin Date: Wed, 1 Apr 2026 11:01:33 -0400 Subject: [PATCH 4/4] 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