Skip to content

Commit cd1693c

Browse files
test: add marker for running tests in subprocess (#3383) (#3520)
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) Co-authored-by: Gabriele N. Tornetta <[email protected]>
1 parent d0e5fb1 commit cd1693c

File tree

12 files changed

+393
-286
lines changed

12 files changed

+393
-286
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
import ddtrace
815
from tests.utils import DummyTracer
@@ -98,3 +105,143 @@ def _snapshot(**kwargs):
98105
yield snapshot
99106

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