Skip to content

Commit 814f793

Browse files
mergify[bot]P403n1x87mabdinur
authored
test: add marker for running tests in subprocess (backport #3383) (#4403)
* test: add marker for running tests in subprocess (#3383) The `subprocess` pytest marker can be used to run arbitrary Python code in a Python subprocess. This is meant to replace the existing fixture to allow actual Python code to be written and tested, instead of using literal strings. ## Checklist - [ ] Added to the correct milestone. - [ ] Tests provided or description of manual testing performed is included in the code or PR. - [ ] Library documentation is updated. - [ ] [Corp site](https://github.com/DataDog/documentation/) documentation is updated (link to the PR). (cherry picked from commit c437ad2) # Conflicts: # tests/contrib/httpx/test_httpx.py # tests/integration/test_integration.py # tests/tracer/test_encoders.py * fix conflicts * Use pytest marker to generate snapshot, set variants pull/4418 Co-authored-by: Gabriele N. Tornetta <[email protected]> Co-authored-by: Munir Abdinur <[email protected]> Co-authored-by: Munir Abdinur <[email protected]>
1 parent 7b37800 commit 814f793

File tree

12 files changed

+394
-288
lines changed

12 files changed

+394
-288
lines changed

conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,25 @@
2828
# Hook for dynamic configuration of pytest in CI
2929
# https://docs.pytest.org/en/6.2.1/reference.html#pytest.hookspec.pytest_configure
3030
def pytest_configure(config):
31+
config.addinivalue_line(
32+
"markers",
33+
"""subprocess(status, out, err, args, env, parametrize, ddtrace_run):
34+
Mark test functions whose body is to be run as stand-alone Python
35+
code in a subprocess.
36+
37+
Arguments:
38+
status: the expected exit code of the subprocess.
39+
out: the expected stdout of the subprocess, or None to ignore.
40+
err: the expected stderr of the subprocess, or None to ignore.
41+
args: the command line arguments to pass to the subprocess.
42+
env: the environment variables to override for the subprocess.
43+
parametrize: whether to parametrize the test function. This is
44+
similar to the `parametrize` marker, but arguments are
45+
passed to the subprocess via environment variables.
46+
ddtrace_run: whether to run the test using ddtrace-run.
47+
""",
48+
)
49+
3150
if os.getenv("CI") != "true":
3251
return
3352

tests/conftest.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import ast
12
import contextlib
3+
from itertools import product
24
import os
35
import sys
6+
from tempfile import NamedTemporaryFile
7+
import time
48

9+
from _pytest.runner import CallInfo
10+
from _pytest.runner import TestReport
511
import pytest
12+
from six import PY2
613

714
from tests.utils import DummyTracer
815
from tests.utils import TracerSpanContainer
@@ -94,3 +101,143 @@ def _snapshot(**kwargs):
94101
yield snapshot
95102

96103
return _snapshot
104+
105+
106+
# DEV: The dump_code_to_file function is adapted from the compile function in
107+
# the py_compile module of the Python standard library. It generates .pyc files
108+
# with the right format.
109+
if PY2:
110+
import marshal
111+
from py_compile import MAGIC
112+
from py_compile import wr_long
113+
114+
from _pytest._code.code import ExceptionInfo
115+
116+
def dump_code_to_file(code, file):
117+
file.write(MAGIC)
118+
wr_long(file, long(time.time())) # noqa
119+
marshal.dump(code, file)
120+
file.flush()
121+
122+
123+
else:
124+
import importlib
125+
126+
code_to_pyc = getattr(
127+
importlib._bootstrap_external, "_code_to_bytecode" if sys.version_info < (3, 7) else "_code_to_timestamp_pyc"
128+
)
129+
130+
def dump_code_to_file(code, file):
131+
file.write(code_to_pyc(code, time.time(), len(code.co_code)))
132+
file.flush()
133+
134+
135+
def unwind_params(params):
136+
if params is None:
137+
yield None
138+
return
139+
140+
for _ in product(*(((k, v) for v in vs) for k, vs in params.items())):
141+
yield dict(_)
142+
143+
144+
class FunctionDefFinder(ast.NodeVisitor):
145+
def __init__(self, func_name):
146+
super(FunctionDefFinder, self).__init__()
147+
self.func_name = func_name
148+
self._body = None
149+
150+
def generic_visit(self, node):
151+
return self._body or super(FunctionDefFinder, self).generic_visit(node)
152+
153+
def visit_FunctionDef(self, node):
154+
if node.name == self.func_name:
155+
self._body = node.body
156+
157+
def find(self, file):
158+
with open(file) as f:
159+
t = ast.parse(f.read())
160+
self.visit(t)
161+
t.body = self._body
162+
return t
163+
164+
165+
def run_function_from_file(item, params=None):
166+
file, _, func = item.location
167+
marker = item.get_closest_marker("subprocess")
168+
169+
file_index = 1
170+
args = marker.kwargs.get("args", [])
171+
args.insert(0, None)
172+
args.insert(0, sys.executable)
173+
if marker.kwargs.get("ddtrace_run", False):
174+
file_index += 1
175+
args.insert(0, "ddtrace-run")
176+
177+
env = os.environ.copy()
178+
env.update(marker.kwargs.get("env", {}))
179+
if params is not None:
180+
env.update(params)
181+
182+
expected_status = marker.kwargs.get("status", 0)
183+
184+
expected_out = marker.kwargs.get("out", "")
185+
if expected_out is not None:
186+
expected_out = expected_out.encode("utf-8")
187+
188+
expected_err = marker.kwargs.get("err", "")
189+
if expected_err is not None:
190+
expected_err = expected_err.encode("utf-8")
191+
192+
with NamedTemporaryFile(mode="wb", suffix=".pyc") as fp:
193+
dump_code_to_file(compile(FunctionDefFinder(func).find(file), file, "exec"), fp.file)
194+
195+
start = time.time()
196+
args[file_index] = fp.name
197+
out, err, status, _ = call_program(*args, env=env)
198+
end = time.time()
199+
excinfo = None
200+
201+
if status != expected_status:
202+
excinfo = AssertionError(
203+
"Expected status %s, got %s.\n=== Captured STDERR ===\n%s=== End of captured STDERR ==="
204+
% (expected_status, status, err.decode("utf-8"))
205+
)
206+
elif expected_out is not None and out != expected_out:
207+
excinfo = AssertionError("STDOUT: Expected [%s] got [%s]" % (expected_out, out))
208+
elif expected_err is not None and err != expected_err:
209+
excinfo = AssertionError("STDERR: Expected [%s] got [%s]" % (expected_err, err))
210+
211+
if PY2 and excinfo is not None:
212+
try:
213+
raise excinfo
214+
except Exception:
215+
excinfo = ExceptionInfo(sys.exc_info())
216+
217+
call_info_args = dict(result=None, excinfo=excinfo, start=start, stop=end, when="call")
218+
if not PY2:
219+
call_info_args["duration"] = end - start
220+
221+
return TestReport.from_item_and_call(item, CallInfo(**call_info_args))
222+
223+
224+
@pytest.hookimpl(tryfirst=True)
225+
def pytest_runtest_protocol(item):
226+
marker = item.get_closest_marker("subprocess")
227+
if marker:
228+
params = marker.kwargs.get("parametrize", None)
229+
ihook = item.ihook
230+
base_name = item.nodeid
231+
232+
for ps in unwind_params(params):
233+
nodeid = (base_name + str(ps)) if ps is not None else base_name
234+
235+
ihook.pytest_runtest_logstart(nodeid=nodeid, location=item.location)
236+
237+
report = run_function_from_file(item, ps)
238+
report.nodeid = nodeid
239+
ihook.pytest_runtest_logreport(report=report)
240+
241+
ihook.pytest_runtest_logfinish(nodeid=nodeid, location=item.location)
242+
243+
return True

tests/contrib/gevent/test_monkeypatch.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,17 @@ def test_gevent_warning(monkeypatch):
2020
assert b"RuntimeWarning: Loading ddtrace before using gevent monkey patching" in subp.stderr.read()
2121

2222

23-
def test_gevent_auto_patching(run_python_code_in_subprocess):
24-
code = """
25-
import ddtrace; ddtrace.patch_all()
26-
27-
import gevent # Patch on import
28-
from ddtrace.contrib.gevent import GeventContextProvider
23+
@pytest.mark.subprocess
24+
def test_gevent_auto_patching():
25+
import ddtrace
2926

27+
ddtrace.patch_all()
28+
# Patch on import
29+
import gevent # noqa
3030

31-
assert isinstance(ddtrace.tracer.context_provider, GeventContextProvider)
32-
"""
31+
from ddtrace.contrib.gevent import GeventContextProvider
3332

34-
out, err, status, pid = run_python_code_in_subprocess(code)
35-
assert status == 0, err
36-
assert out == b""
33+
assert isinstance(ddtrace.tracer.context_provider, GeventContextProvider)
3734

3835

3936
def test_gevent_ddtrace_run_auto_patching(ddtrace_run_python_code_in_subprocess):

0 commit comments

Comments
 (0)