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