Belle II Software light-2601-hyperion
__init__.py
1#!/usr/bin/env python3
2
3
10
11"""
12b2test_utils - Helper functions useful for test scripts
13-------------------------------------------------------
14
15This module contains functions which are commonly needed for tests like changing
16log levels or switching to an empty working directory
17"""
18
19import sys
20import os
21import tempfile
22from contextlib import contextmanager
23from collections import OrderedDict
24import multiprocessing
25import basf2
26import subprocess
27import re
28from b2test_utils import logfilter
29
30
31def skip_test(reason, py_case=None):
32 """Skip a test script with a given reason. This function will end the script
33 and not return.
34
35 This is intended for scripts to be run in :ref:`b2test-scripts
36 <b2test-scripts>` and will flag the script as skipped with the given reason
37 when tests are executed.
38
39 Useful if the test depends on some external condition like a web service and
40 missing this dependency should not fail the test run.
41
42 Parameters:
43 reason (str): the reason to skip the test.
44 py_case (unittest.TestCase): if this is to be skipped within python's
45 native unittest then pass the TestCase instance
46 """
47 if py_case:
48 py_case.skipTest(reason)
49 else:
50 print(f"TEST SKIPPED: {reason}", file=sys.stderr, flush=True)
51 sys.exit(1)
52
53
54def require_file(filename, data_type="", py_case=None):
55 """Check for the existence of a test input file before attempting to open it.
56 Skips the test if not found.
57
58 Wraps `basf2.find_file` for use in test scripts run as
59 :ref`b2test-scripts <b2test-scripts>`
60
61 Parameters:
62 filename (str): relative filename to look for, either in a central place or in the current working directory
63 data_type (str): case insensitive data type to find. Either empty string or one of ``"examples"``, ``"validation"``
64 or ``"starterkit"``.
65 py_case (unittest.TestCase): if this is to be skipped within python's native unittest then pass the TestCase instance
66
67 Returns:
68 Full path to the test input file
69 """
70 try:
71 fullpath = basf2.find_file(filename, data_type, silent=False)
72 except FileNotFoundError as fnf:
73 skip_test(f'Cannot find: {fnf.filename}', py_case)
74 return fullpath
75
76
77@contextmanager
78def set_loglevel(loglevel):
79 """
80 temporarily set the log level to the specified `LogLevel <basf2.LogLevel>`. This returns a
81 context manager so it should be used in a ``with`` statement:
82
83 >>> with set_log_level(LogLevel.ERROR):
84 >>> # during this block the log level is set to ERROR
85 """
86 old_loglevel = basf2.logging.log_level
87 basf2.set_log_level(loglevel)
88 try:
89 yield
90 finally:
91 basf2.set_log_level(old_loglevel)
92
93
94@contextmanager
95def show_only_errors():
96 """temporarily set the log level to `ERROR <LogLevel.ERROR>`. This returns a
97 context manager so it should be used in a ``with`` statement
98
99 >>> with show_only_errors():
100 >>> B2INFO("this will not be shown")
101 >>> B2INFO("but this might")
102 """
103 with set_loglevel(basf2.LogLevel.ERROR):
104 yield
105
106
107def configure_logging_for_tests(user_replacements=None, replace_cdb_provider=True):
108 """
109 Change the log system to behave a bit more appropriately for testing scenarios:
110
111 1. Simplify log message to be just ``[LEVEL] message``
112 2. Disable error summary, just additional noise
113 3. Intercept all log messages and replace
114
115 * the current working directory in log messaged with ``${cwd}``
116 * the current release version with ``${release_version}``
117 * the current default globaltags with ``${default_globaltag}``
118 * the contents of the following environment variables with their name
119 (or the listed replacement string):
120
121 - :envvar:`BELLE2_TOOLS`
122 - :envvar:`BELLE2_RELEASE_DIR` with ``BELLE2_SOFTWARE_DIR``
123 - :envvar:`BELLE2_LOCAL_DIR` with ``BELLE2_SOFTWARE_DIR``
124 - :envvar:`BELLE2_EXTERNALS_DIR`
125 - :envvar:`BELLE2_VALIDATION_DATA_DIR`
126 - :envvar:`BELLE2_EXAMPLES_DATA_DIR`
127 - :envvar:`BELLE2_BACKGROUND_DIR`
128 - :envvar:`BELLE2_CONDB_METADATA`
129
130 Parameters:
131 user_replacements (dict(str, str)): Additional strings and their replacements to replace in the output
132 replace_cdb_provider (bool): If False, it does not replace the conditions database metadata provider with
133 `BELLE2_CONDB_METADATA` (necessary for some specific tests)
134
135 Warning:
136 This function should be called **after** switching directory to replace the correct directory name
137
138 .. versionadded:: release-04-00-00
139 """
140 basf2.logging.reset()
141 basf2.logging.enable_summary(False)
142 basf2.logging.enable_python_logging = True
143 basf2.logging.add_console()
144 # clang prints namespaces differently so no function names. Also let's skip the line number,
145 # we don't want failing tests just because we added a new line of code. In fact, let's just see the message
146 for level in basf2.LogLevel.values.values():
147 basf2.logging.set_info(level, basf2.LogInfo.LEVEL | basf2.LogInfo.MESSAGE)
148
149 # now create dictionary of string replacements. Since each key can only be
150 # present once order is kind of important so the less portable ones like
151 # current directory should go first and might be overridden if for example
152 # the BELLE2_LOCAL_DIR is identical to the current working directory
153 replacements = OrderedDict()
154 try:
155 replacements[basf2.version.get_version()] = "${release_version}"
156 except Exception:
157 pass
158 replacements[", ".join(basf2.conditions.default_globaltags)] = "${default_globaltag}"
159 # add a special replacement for the CDB metadata provider, since it's not set via env. variable
160 # use the first metadata provider in the list for the replacement
161 if replace_cdb_provider and len(basf2.conditions.metadata_providers) > 0:
162 replacements[basf2.conditions.metadata_providers[0]] = "${BELLE2_CONDB_METADATA}"
163 # Let's be lazy and take the environment variables from the docstring so we don't have to repeat them here
164 for env_name, replacement in re.findall(":envvar:`(.*?)`(?:.*``(.*?)``)?", configure_logging_for_tests.__doc__):
165 if not replacement:
166 replacement = env_name
167 if env_name in os.environ:
168 # replace path from the environment with the name of the variable. But remove a trailing slash or whitespace so that
169 # the output doesn't depend on whether there is a tailing slash in the environment variable
170 replacements[os.environ[env_name].rstrip('/ ')] = f"${{{replacement}}}"
171
172 if user_replacements is not None:
173 replacements.update(user_replacements)
174 # add cwd only if it doesn't overwrite anything ...
175 replacements.setdefault(os.getcwd(), "${cwd}")
176 sys.stdout = logfilter.LogReplacementFilter(sys.__stdout__, replacements)
177
178
179@contextmanager
180def working_directory(path):
181 """temporarily change the working directory to path
182
183 >>> with working_directory("testing"):
184 >>> # now in subdirectory "./testing/"
185 >>> # back to parent directory
186
187 This function will not create the directory for you. If changing into the
188 directory fails a `FileNotFoundError` will be raised.
189 """
190 dirname = os.getcwd()
191 try:
192 os.chdir(path)
193 yield
194 finally:
195 os.chdir(dirname)
196
197
198@contextmanager
199def clean_working_directory():
200 """Context manager to create a temporary directory and directly us it as
201 current working directory. The directory will automatically be deleted after
202 the with context is left.
203
204 >>> with clean_working_directory() as dirname:
205 >>> # now we are in an empty directory, name is stored in dirname
206 >>> assert(os.listdir() == [])
207 >>> # now we are back where we were before
208 """
209 with tempfile.TemporaryDirectory() as tempdir:
210 with working_directory(tempdir):
211 yield tempdir
212
213
214@contextmanager
215def local_software_directory():
216 """Context manager to make sure we are executed in the top software
217 directory by switching to $BELLE2_LOCAL_DIR or $BELLE2_RELEASE_DIR.
218
219 >>> with local_software_directory():
220 >>> assert(os.listdir().contains("analysis"))
221 """
222 directory = os.environ.get("BELLE2_LOCAL_DIR", os.environ.get("BELLE2_RELEASE_DIR", None))
223 if directory is None:
224 raise RuntimeError("Cannot find Belle II software directory, "
225 "have you setup the software correctly?")
226
227 with working_directory(directory):
228 yield directory
229
230
231def run_in_subprocess(*args, target, **kwargs):
232 """Run the given ``target`` function in a child process using `multiprocessing.Process`
233
234 This avoids side effects: anything done in the target function will not
235 affect the current process. This is mostly useful for test scripts as
236 ``target`` can emit a `FATAL <LogLevel.FATAL>` error without killing script execution.
237
238 It will return the exitcode of the child process which should be 0 in case of no error
239 """
240 process = multiprocessing.Process(target=target, args=args, kwargs=kwargs)
241 process.start()
242 process.join()
243 return process.exitcode
244
245
246def safe_process(*args, **kwargs):
247 """Run `basf2.process` with the given path in a child process using
248 `multiprocessing.Process`
249
250 This avoids side effects (`safe_process` can be safely called multiple times)
251 and doesn't kill this script even if a segmentation violation or a `FATAL
252 <LogLevel.FATAL>` error occurs during processing.
253
254 It will return the exitcode of the child process which should be 0 in case of no error
255 """
256 return run_in_subprocess(target=basf2.process, *args, **kwargs)
257
258
259def check_error_free(tool, toolname, package, filter=lambda x: False, toolopts=None):
260 """Calls the ``tool`` with argument ``package`` and check that the output is
261 error-free. Optionally ``filter`` the output in case of error messages that
262 can be ignored.
263
264 In case there is some output left, then prints the error message and exits
265 (failing the test).
266
267 Warnings:
268 If the test is skipped or the test contains errors this function does
269 not return but will directly end the program.
270
271 Arguments:
272 tool(str): executable to call
273 toolname(str): human readable name of the tool
274 package(str): package to run over. Also the first argument to the tool
275 filter: function which gets called for each line of output and
276 if it returns True the line will be ignored.
277 toolopts(list(str)): extra options to pass to the tool.
278 """
279
280 if "BELLE2_LOCAL_DIR" not in os.environ and "BELLE2_RELEASE_DIR" not in os.environ:
281 skip_test("No release is setup")
282
283 args = [tool]
284 if toolopts:
285 args += toolopts
286 if package is not None:
287 args += [package]
288
289 with local_software_directory():
290 try:
291 output = subprocess.check_output(args, encoding="utf8")
292 except subprocess.CalledProcessError as error:
293 print(error)
294 output = error.output
295
296 clean_log = [e for e in output.splitlines() if e and not filter(e)]
297 if len(clean_log) > 0:
298 subject = f"{package} package" if package is not None else "repository"
299 print(f"""\
300The {subject} has some {toolname} issues, which is now not allowed.
301Please run:
302
303 $ {" ".join(args)}
304
305and fix any issues you have introduced. Here is what {toolname} found:\n""")
306 print("\n".join(clean_log))
307 sys.exit(1)
308
309
310def get_streamer_checksums(objects):
311 """
312 Extract the version and streamer checksum of the C++ objects in the given list
313 by writing them all to a TMemFile and getting back the streamer info list
314 automatically created by ROOT afterwards.
315 Please note, that this list also includes the streamer infos of all
316 base objects of the objects you gave.
317
318 Returns a dictionary object name -> (version, checksum).
319 """
320 # Always avoid the top-level 'import ROOT'.
321 import ROOT # noqa
322
323 # Write out the objects to a mem file
324 f = ROOT.TMemFile("test_mem_file", "RECREATE")
325 f.cd()
326
327 for o in objects:
328 o.Write()
329 f.Write()
330
331 # Go through all streamer infos and extract checksum and version
332 streamer_checksums = dict()
333 for streamer_info in f.GetStreamerInfoList():
334 if not isinstance(streamer_info, ROOT.TStreamerInfo):
335 continue
336 streamer_checksums[streamer_info.GetName()] = (streamer_info.GetClassVersion(), streamer_info.GetCheckSum())
337
338 f.Close()
339 return streamer_checksums
340
341
342def get_object_with_name(object_name, root=None):
343 """
344 (Possibly) recursively get the object with the given name from the Belle2 namespace.
345
346 If the object name includes a ".", the first part will be turned into an object (probably a module)
347 and the function is continued with this object as the root and the rest of the name.
348
349 If not, the object is extracted via a getattr call.
350 """
351 if root is None:
352 from ROOT import Belle2 # noqa
353 root = Belle2
354
355 if "." in object_name:
356 namespace, object_name = object_name.split(".", 1)
357
358 return get_object_with_name(object_name, get_object_with_name(namespace, root=root))
359
360 return getattr(root, object_name)
361
362
363def skip_test_if_light(py_case=None):
364 """
365 Skips the test if we are running in a light build (maybe this test tests
366 some generation example or whatever)
367
368 Parameters:
369 py_case (unittest.TestCase): if this is to be skipped within python's
370 native unittest then pass the TestCase instance
371 """
372 try:
373 import generators # noqa
374 except ModuleNotFoundError:
375 skip_test(reason="We're in a light build.", py_case=py_case)
376
377
378def skip_test_if_central(py_case=None):
379 """
380 Skips the test if we are using a central release (and have no local
381 git repository)
382
383 Parameters:
384 py_case (unittest.TestCase): if this is to be skipped within python's
385 native unittest then pass the TestCase instance
386 """
387 if "BELLE2_RELEASE_DIR" in os.environ:
388 skip_test(reason="We're in a central release.", py_case=py_case)
389
390
391def print_belle2_environment():
392 """
393 Prints all the BELLE2 environment variables on the screen.
394 """
395 basf2.B2INFO('The BELLE2 environment variables are:')
396 for key, value in sorted(dict(os.environ).items()):
397 if 'BELLE2' in key.upper():
398 print(f' {key}={value}')
399
400
401@contextmanager
402def temporary_environment(**environ):
403 """
404 Context manager that temporarily sets environment variables
405 for the current process. Inspired by https://stackoverflow.com/a/34333710
406
407 >>> with temporary_environment(BELLE2_TEMP_DIR='/tmp/belle2'):
408 ... "BELLE2_TEMP_DIR" in os.environ
409 True
410
411 >>> "BELLE2_TEMP_DIR" in os.environ
412 False
413
414 Args:
415 **environ: Arbitrary keyword arguments specifying environment
416 variables and their values.
417 """
418 old_environ = dict(os.environ)
419 os.environ.update(environ)
420 try:
421 yield
422 finally:
423 os.environ.clear()
424 os.environ.update(old_environ)
425
426
427def is_ci() -> bool:
428 """
429 Returns true if we are running a test on our CI system (currently GitLab pipeline).
430 The 'BELLE2_IS_CI' environment variable is set on CI only when the unit
431 tests are run.
432 """
433 return os.environ.get("BELLE2_IS_CI", "no").lower() in [
434 "yes",
435 "1",
436 "y",
437 "on",
438 ]
439
440
441def is_cdb_down() -> bool:
442 """
443 Returns true if the Conditions Database (CDB) is currently unavailable or slow to respond.
444 The 'BELLE2_IS_CDB_DOWN' environment variable can be used to dynamically exclude some
445 tests that rely on the CDB in case of problems.
446 """
447 return os.environ.get("BELLE2_IS_CDB_DOWN", "no").lower() in [
448 "yes",
449 "1",
450 "y",
451 "on",
452 ]