The Gaudi Framework  master (6507eef1)
Loading...
Searching...
No Matches
collect_for_ctest.py
Go to the documentation of this file.
11"""
12pytest plugin that report collected pytest files as CTest tests
13
14This plugin is not meant to be used directly, but it is invoked by the
15CMake function `gaudi_add_pytest()`
16"""
17
18import os
19import re
20from collections import defaultdict
21from pathlib import Path
22
23
24def pytest_addoption(parser, pluginmanager):
25 parser.addoption(
26 "--ctest-output-file",
27 help="name of the file to write to communicate to ctest the discovered tests",
28 )
29 parser.addoption(
30 "--ctest-pytest-command",
31 default="pytest",
32 help="how pytest has to be invoked (e.g. using wrapper commands)",
33 )
34 parser.addoption(
35 "--ctest-pytest-root-dir",
36 default=Path.cwd(),
37 help="root directory to compute test names",
38 )
39 parser.addoption(
40 "--ctest-prefix",
41 default="",
42 help="string to prefix to the generated test names",
43 )
44 parser.addoption(
45 "--ctest-label",
46 default=["pytest"],
47 action="append",
48 help="labels to attach to the test (the label pytest is always added)",
49 )
50 parser.addoption(
51 "--ctest-properties",
52 default=[],
53 action="append",
54 help="test properties to set for all discovered tests",
55 )
56 parser.addoption(
57 "--ctest-binary-dir",
58 default=None,
59 help="value of CMAKE_CURRENT_BINARY_DIR from which gaudi_add_pytest was invoked",
60 )
61 parser.addoption(
62 "--ctest-coverage",
63 default="",
64 help="select modules for which produce coverage reports",
65 )
66 parser.addoption(
67 "--ctest-coverage-command",
68 default="coverage report",
69 help="how coverage should be invoked to produce the final report",
70 )
71
72
73def pytest_collectstart(collector):
74 session, config = collector.session, collector.config
75 args = {
76 name[6:]: getattr(config.option, name)
77 for name in dir(config.option)
78 if name.startswith("ctest_")
79 }
80
81 if args.get("binary_dir"):
82 # $CMAKE_CURRENT_BINARY_DIR will be set by CTest when the test is run
83 # so, for consistency, we set it at collection time too
84 os.environ["CMAKE_CURRENT_BINARY_DIR"] = args["binary_dir"]
85
86 session.ctest_args = args
87
88
89def pytest_collection_modifyitems(session, config, items):
90 if not session.ctest_args.get("output_file"):
91 # nothing to do if no output file is specified
92 return
93
94 session.ctest_files = set(item.path for item in items)
95 session.ctest_fixture_setup = defaultdict(set)
96 session.ctest_fixture_required = defaultdict(set)
97 for item in items:
98 for marker in ("ctest_fixture_setup", "ctest_fixture_required"):
99 for mark in item.iter_markers(name=marker):
100 getattr(session, marker)[item.path].update(mark.args)
101
102
103TEST_DESC_TEMPLATE = """
104add_test({name} {pytest_cmd} {path})
105set_tests_properties({name} PROPERTIES {properties})
106"""
107
108
110 """Resolve a bare sanitizer runtime library name (e.g. "libasan.so") to
111 an absolute path by searching LD_LIBRARY_PATH ourselves, instead of
112 leaving it bare for the dynamic loader to search for later.
113
114 PRELOAD_SANITIZER_LIB (set by the sanitizer toolchain, see
115 lcg-toolchains' fragments/sanitizers/settings.cmake) is exported as a
116 bare filename. That's fine here: this function runs inside the same
117 `run`-wrapper environment used to launch this very pytest collection
118 process, which is known to have LD_LIBRARY_PATH set up correctly (the
119 sanitizer library does get found by *something* in this process, or
120 ASan wouldn't be able to report "runtime does not come first in
121 initial library list" at all -- it would fail to load entirely). But
122 that's not guaranteed for whatever process actually *runs* each test
123 later, which is a different invocation context (verified: pytest-based
124 CTest tests print "ld.so: ... cannot be preloaded (cannot open shared
125 object file): ignored" and silently run without the sanitizer active).
126 Resolving to an absolute path now, once, and baking it into each
127 test's ENVIRONMENT property removes that dependency entirely -- an
128 absolute path needs no search at test-run time.
129 """
130 if os.path.isabs(name):
131 return name
132 for directory in os.environ.get("LD_LIBRARY_PATH", "").split(":"):
133 if directory and os.path.isfile(os.path.join(directory, name)):
134 return os.path.join(directory, name)
135 return name # not found: fall back to the bare name, as before
136
137
139 args = session.ctest_args
140 output_filename = args.get("output_file")
141
142 if not output_filename:
143 # nothing to do if no output file is specified
144 return
145
146 output = open(output_filename, "w")
147 output_rootdir = Path(output_filename).parent
148
149 coverage = args["coverage"].split(",") if args["coverage"] else []
150 if coverage:
151 args["pytest_command"] += " --cov-report= --cov-reset " + " ".join(
152 f"--cov={module}" for module in coverage
153 )
154
155 properties = 'LABELS "{}" '.format(";".join(args["label"]))
156 env_entries = []
157 if args.get("binary_dir"):
158 env_entries.append(f"CMAKE_CURRENT_BINARY_DIR={args['binary_dir']}")
159 preload_sanitizer_lib = os.environ.get("PRELOAD_SANITIZER_LIB")
160 if preload_sanitizer_lib:
161 resolved = ":".join(
163 for name in preload_sanitizer_lib.split(":")
164 if name
165 )
166 env_entries.append(f"LD_PRELOAD={resolved}")
167 if env_entries:
168 properties += 'ENVIRONMENT "{}" '.format(";".join(env_entries))
169 properties += " ".join(args["properties"])
170
171 producers = defaultdict(list) # test name -> list of fixtures it produces
172 consumers = defaultdict(list) # test name -> list of fixtures it depends on
173 fixtures = defaultdict(list) # fixture name -> list of tests that produce it
174 names = []
175 for path in sorted(session.ctest_files):
176 name = os.path.relpath(path, args["pytest_root_dir"]).replace("/", ".")
177 if name.endswith(".py"):
178 name = name[:-3]
179 if name == ".":
180 # when we pass a single file to pytest, pytest_root_dir and path are the same
181 # and relpath returns ".", so we take the prefix and drop the final dot
182 # (see https://gitlab.cern.ch/gaudi/Gaudi/-/issues/354)
183 name = args["prefix"][:-1]
184 else:
185 name = args["prefix"] + name
186 pytest_cmd = args["pytest_command"]
187
188 output.write(
189 TEST_DESC_TEMPLATE.format(
190 name=name,
191 path=path,
192 pytest_cmd=pytest_cmd,
193 properties=properties,
194 )
195 )
196
197 if session.ctest_fixture_setup.get(path):
198 for fixture in session.ctest_fixture_setup[path]:
199 producers[name].append(fixture)
200 fixtures[fixture].append(name)
201
202 if session.ctest_fixture_required.get(path):
203 for fixture in session.ctest_fixture_required[path]:
204 consumers[name].append(fixture)
205
206 names.append(name)
207
208 for name in names:
209 if name in producers:
210 output.write(
211 'set_tests_properties('
212 f'{name} PROPERTIES FIXTURES_SETUP "{";".join(producers[name])}")\n'
213 )
214
215 if name in consumers:
216 producer_test_names = []
217 for fixture in consumers[name]:
218 producer_test_names.extend(fixtures[fixture])
219 output.write(
220 'if (DEFINED ENV{PYTEST_DISABLE_FIXTURES_REQUIRED})\n'
221 f' set_tests_properties({name} PROPERTIES DEPENDS "{";".join(producer_test_names)}")\n'
222 'else()\n'
223 f' set_tests_properties({name} PROPERTIES FIXTURES_REQUIRED "{";".join(consumers[name])}")\n'
224 'endif()\n'
225 )
226
227 # we force one test to be run one by one
228 if coverage:
229 # generate one coverage file per test
230 output.write(
231 f"set_tests_properties({name} PROPERTIES ENVIRONMENT COVERAGE_FILE={output_rootdir}/.coverage.{name})\n"
232 f"set_tests_properties({name} PROPERTIES FIXTURES_SETUP {name})\n"
233 )
234
235 if coverage and names:
236 combine_test = (args["prefix"] + "coverage_combine").replace("/", ".")
237 combine_command = re.sub(
238 r" report .*",
239 f" combine {' '.join(f'{output_rootdir}/.coverage.{n}' for n in names)}",
240 args["coverage_command"],
241 )
242 output.write(
243 TEST_DESC_TEMPLATE.format(
244 name=combine_test,
245 path="",
246 pytest_cmd=combine_command,
247 properties=properties,
248 )
249 )
250 output.write(
251 f"set_tests_properties({combine_test} PROPERTIES ENVIRONMENT COVERAGE_FILE={output_rootdir}/.coverage)\n"
252 f"set_tests_properties({combine_test} PROPERTIES FIXTURES_REQUIRED \"{';'.join(names)}\")\n"
253 f"set_tests_properties({combine_test} PROPERTIES FIXTURES_SETUP {combine_test})\n"
254 )
255
256 name = (args["prefix"] + "coverage_report").replace("/", ".")
257 output.write(
258 TEST_DESC_TEMPLATE.format(
259 name=name,
260 path="",
261 pytest_cmd=f"{args['coverage_command']}",
262 properties=properties + " LABELS coverage",
263 )
264 )
265 # coverage reports require all related tests to be run first
266 output.write(
267 f"set_tests_properties({name} PROPERTIES ENVIRONMENT COVERAGE_FILE={output_rootdir}/.coverage)\n"
268 f"set_tests_properties({name} PROPERTIES FIXTURES_REQUIRED {combine_test})\n"
269 )
270
271 output.close()
272
273
275 config.addinivalue_line(
276 "markers",
277 "ctest_fixture_setup(name): mark test to set up a fixture needed by another test",
278 )
279 config.addinivalue_line(
280 "markers",
281 "ctest_fixture_required(name): mark test to require a fixture set up by another test",
282 )
GAUDI_API std::string format(const char *,...)
MsgStream format utility "a la sprintf(...)".
Definition MsgStream.cpp:93
pytest_collectstart(collector)
pytest_collection_finish(session)
pytest_addoption(parser, pluginmanager)
pytest_collection_modifyitems(session, config, items)