Skip to content

Commit 465edcd

Browse files
fix[pytest]: Default JSON encoding for non-serializable types to __repr__ (backport #2660) (#2671)
* fix[pytest]: Default JSON encoding for non-serializable types to __repr__ (#2660) * Default JSON encode to __repr__ for all non-serializable cases. * Added MagicMock circular reference test case * Added custom circular reference object for test case. * Spelling wordlist addition for changelog * Made test compatible with py2 and py3 Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> (cherry picked from commit 5e6fc1b) # Conflicts: # ddtrace/contrib/pytest/plugin.py # tests/contrib/pytest/test_pytest.py * Merge conflict resolution for backport Co-authored-by: Yun Kim <[email protected]> Co-authored-by: Yun Kim <[email protected]>
1 parent cd8f4f9 commit 465edcd

File tree

4 files changed

+59
-113
lines changed

4 files changed

+59
-113
lines changed

ddtrace/contrib/pytest/plugin.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import json
2-
from typing import Any
3-
from typing import Dict
42

53
import pytest
64

@@ -32,20 +30,6 @@ def _store_span(item, span):
3230
setattr(item, "_datadog_span", span)
3331

3432

35-
def _json_encode(params):
36-
# type: (Dict[str, Any]) -> str
37-
"""JSON encode parameters. If complex object show inner values, otherwise default to string representation."""
38-
39-
def inner_encode(obj):
40-
try:
41-
obj_dict = getattr(obj, "__dict__", None)
42-
return obj_dict if obj_dict else repr(obj)
43-
except Exception as e:
44-
return repr(e)
45-
46-
return json.dumps(params, default=inner_encode)
47-
48-
4933
PATCH_ALL_HELP_MSG = "Call ddtrace.patch_all before running tests."
5034

5135

@@ -124,7 +108,7 @@ def pytest_runtest_protocol(item, nextitem):
124108
# Pytest docs: https://docs.pytest.org/en/6.2.x/reference.html#pytest.Function
125109
if getattr(item, "callspec", None):
126110
params = {"arguments": item.callspec.params, "metadata": {}}
127-
span.set_tag(test.PARAMETERS, _json_encode(params))
111+
span.set_tag(test.PARAMETERS, json.dumps(params, default=repr))
128112

129113
markers = [marker.kwargs for marker in item.iter_markers(name="dd_tags")]
130114
for tags in markers:

docs/spelling_wordlist.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ iPython
6464
initializer
6565
integration
6666
integrations
67+
JSON
6768
jinja
6869
kombu
6970
kubernetes
@@ -111,6 +112,7 @@ runnable
111112
runtime
112113
runtime-id
113114
sanic
115+
serializable
114116
sql
115117
sqlalchemy
116118
sqlite
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed JSON encoding errors in the pytest plugin for parameterized tests with complex Python object parameters.
5+
The pytest plugin now defaults to encoding the string representations of non-JSON serializable test parameters.

tests/contrib/pytest/test_pytest.py

Lines changed: 51 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@
22
import os
33
import sys
44

5-
from hypothesis import given
6-
from hypothesis import strategies as st
75
import pytest
86

97
from ddtrace import Pin
10-
from ddtrace.contrib.pytest.plugin import _json_encode
118
from ddtrace.ext import test
129
from tests.utils import TracerTestCase
1310

@@ -106,11 +103,36 @@ def test_ini(ddspan):
106103
assert len(spans) == 1
107104

108105
def test_parameterize_case(self):
109-
"""Test parametrize case."""
106+
"""Test parametrize case with simple objects."""
110107
py_file = self.testdir.makepyfile(
111108
"""
112109
import pytest
113110
111+
112+
@pytest.mark.parametrize('item', [1, 2, 3, 4, pytest.param([1, 2, 3], marks=pytest.mark.skip)])
113+
class Test1(object):
114+
def test_1(self, item):
115+
assert item in {1, 2, 3}
116+
"""
117+
)
118+
file_name = os.path.basename(py_file.strpath)
119+
rec = self.inline_run("--ddtrace", file_name)
120+
rec.assertoutcome(passed=3, failed=1, skipped=1)
121+
spans = self.pop_spans()
122+
123+
expected_params = [1, 2, 3, 4, [1, 2, 3]]
124+
assert len(spans) == 5
125+
for i in range(len(expected_params)):
126+
extracted_params = json.loads(spans[i].meta[test.PARAMETERS])
127+
assert extracted_params == {"arguments": {"item": expected_params[i]}, "metadata": {}}
128+
129+
def test_parameterize_case_complex_objects(self):
130+
"""Test parametrize case with complex objects."""
131+
py_file = self.testdir.makepyfile(
132+
"""
133+
from mock import MagicMock
134+
import pytest
135+
114136
class A:
115137
def __init__(self, name, value):
116138
self.name = name
@@ -119,19 +141,19 @@ def __init__(self, name, value):
119141
def item_param():
120142
return 42
121143
144+
circular_reference = A("circular_reference", A("child", None))
145+
circular_reference.value.value = circular_reference
146+
122147
@pytest.mark.parametrize(
123-
'item',
124-
[
125-
1,
126-
2,
127-
3,
128-
4,
129-
pytest.param(A("test_name", "value"), marks=pytest.mark.skip),
130-
pytest.param(A("test_name", A("inner_name", "value")), marks=pytest.mark.skip),
131-
pytest.param({"a": A("test_name", "value"), "b": [1, 2, 3]}, marks=pytest.mark.skip),
132-
pytest.param([1, 2, 3], marks=pytest.mark.skip),
133-
pytest.param(item_param, marks=pytest.mark.skip)
134-
]
148+
'item',
149+
[
150+
pytest.param(A("test_name", "value"), marks=pytest.mark.skip),
151+
pytest.param(A("test_name", A("inner_name", "value")), marks=pytest.mark.skip),
152+
pytest.param(item_param, marks=pytest.mark.skip),
153+
pytest.param({"a": A("test_name", "value"), "b": [1, 2, 3]}, marks=pytest.mark.skip),
154+
pytest.param(MagicMock(value=MagicMock()), marks=pytest.mark.skip),
155+
pytest.param(circular_reference, marks=pytest.mark.skip),
156+
]
135157
)
136158
class Test1(object):
137159
def test_1(self, item):
@@ -140,24 +162,22 @@ def test_1(self, item):
140162
)
141163
file_name = os.path.basename(py_file.strpath)
142164
rec = self.inline_run("--ddtrace", file_name)
143-
rec.assertoutcome(passed=3, failed=1, skipped=5)
165+
rec.assertoutcome(skipped=6)
144166
spans = self.pop_spans()
145167

146-
expected_params = [
147-
1,
148-
2,
149-
3,
150-
4,
151-
{"name": "test_name", "value": "value"},
152-
{"name": "test_name", "value": {"name": "inner_name", "value": "value"}},
153-
{"a": {"name": "test_name", "value": "value"}, "b": [1, 2, 3]},
154-
[1, 2, 3],
168+
# Since object will have arbitrary addresses, only need to ensure that
169+
# the params string contains most of the string representation of the object.
170+
expected_params_contains = [
171+
"test_parameterize_case_complex_objects.A",
172+
"test_parameterize_case_complex_objects.A",
173+
"<function item_param at 0x",
174+
'"a": "<test_parameterize_case_complex_objects.A',
175+
"<MagicMock id=",
176+
"test_parameterize_case_complex_objects.A",
155177
]
156-
assert len(spans) == 9
157-
for i in range(len(spans) - 1):
158-
extracted_params = json.loads(spans[i].meta[test.PARAMETERS])
159-
assert extracted_params == {"arguments": {"item": expected_params[i]}, "metadata": {}}
160-
assert "<function item_param at 0x" in json.loads(spans[8].meta[test.PARAMETERS])["arguments"]["item"]
178+
assert len(spans) == 6
179+
for i in range(len(expected_params_contains)):
180+
assert expected_params_contains[i] in spans[i].meta[test.PARAMETERS]
161181

162182
def test_skip(self):
163183
"""Test parametrize case."""
@@ -329,68 +349,3 @@ def test_service(ddspan):
329349
file_name = os.path.basename(py_file.strpath)
330350
rec = self.subprocess_run("--ddtrace", file_name)
331351
assert 0 == rec.ret
332-
333-
334-
class A(object):
335-
def __init__(self, name, value):
336-
self.name = name
337-
self.value = value
338-
339-
340-
simple_types = [st.none(), st.booleans(), st.text(), st.integers(), st.floats(allow_infinity=False, allow_nan=False)]
341-
complex_types = [st.functions(), st.dates(), st.decimals(), st.builds(A, name=st.text(), value=st.integers())]
342-
343-
344-
@given(
345-
st.dictionaries(
346-
st.text(),
347-
st.one_of(
348-
st.lists(st.one_of(*simple_types)), st.dictionaries(st.text(), st.one_of(*simple_types)), *simple_types
349-
),
350-
)
351-
)
352-
def test_custom_json_encoding_simple_types(obj):
353-
"""Ensures the _json.encode helper encodes simple objects."""
354-
encoded = _json_encode(obj)
355-
decoded = json.loads(encoded)
356-
assert obj == decoded
357-
358-
359-
@given(
360-
st.dictionaries(
361-
st.text(),
362-
st.one_of(
363-
st.lists(st.one_of(*complex_types)), st.dictionaries(st.text(), st.one_of(*complex_types)), *complex_types
364-
),
365-
)
366-
)
367-
def test_custom_json_encoding_python_objects(obj):
368-
"""Ensures the _json_encode helper encodes complex objects into dicts of inner values or a string representation."""
369-
encoded = _json_encode(obj)
370-
obj = json.loads(
371-
json.dumps(obj, default=lambda x: getattr(x, "__dict__", None) if getattr(x, "__dict__", None) else repr(x))
372-
)
373-
decoded = json.loads(encoded)
374-
assert obj == decoded
375-
376-
377-
def test_custom_json_encoding_side_effects():
378-
"""Ensures the _json_encode helper encodes objects with side effects (getattr, repr) without raising exceptions."""
379-
dict_side_effect = Exception("side effect __dict__")
380-
repr_side_effect = Exception("side effect __repr__")
381-
382-
class B(object):
383-
def __getattribute__(self, item):
384-
if item == "__dict__":
385-
raise dict_side_effect
386-
raise AttributeError()
387-
388-
class C(object):
389-
def __repr__(self):
390-
raise repr_side_effect
391-
392-
obj = {"b": B(), "c": C()}
393-
encoded = _json_encode(obj)
394-
decoded = json.loads(encoded)
395-
assert decoded["b"] == repr(dict_side_effect)
396-
assert decoded["c"] == repr(repr_side_effect)

0 commit comments

Comments
 (0)