Belle II Software  release-08-01-10
validationfunctions.py
1 #!/usr/bin/env python3
2 
3 
10 
11 # Import timeit module and start a timer. Allows to get the runtime of the
12 # program at any given point
13 import timeit
14 
15 g_start_time = timeit.default_timer() # noqa
16 
17 # std
18 import argparse
19 import glob
20 import os
21 import subprocess
22 import sys
23 import time
24 from typing import Dict, Optional, List, Union
25 import logging
26 from pathlib import Path
27 
28 # 3rd party
29 import ROOT
30 
31 # ours
32 import validationpath
33 
34 
37 
38 
39 def get_timezone() -> str:
40  """
41  Returns the correct timezone as short string
42  """
43  tz_tuple = time.tzname
44 
45  # in some timezones, there is a daylight saving times entry in the
46  # second item of the tuple
47  if time.daylight != 0:
48  return tz_tuple[1]
49  else:
50  return tz_tuple[0]
51 
52 
53 def get_compact_git_hash(repo_folder: str) -> Optional[str]:
54  """
55  Returns the compact git hash from a folder inside of a git repository
56  """
57  try:
58  cmd_output = (
59  subprocess.check_output(
60  ["git", "show", "--oneline", "-s"], cwd=repo_folder
61  )
62  .decode()
63  .rstrip()
64  )
65  # the first word in this string will be the hash
66  cmd_output = cmd_output.split(" ")
67  if len(cmd_output) > 1:
68  return cmd_output[0]
69  else:
70  # something went wrong
71  return
72  except subprocess.CalledProcessError:
73  return
74 
75 
76 def basf2_command_builder(
77  steering_file: str, parameters: List[str], use_multi_processing=False
78 ) -> List[str]:
79  """
80  This utility function takes the steering file name and other basf2
81  parameters and returns a list which can be executed via the OS shell for
82  example to subprocess.Popen(params ...) If use_multi_processing is True,
83  the script will be executed in multi-processing mode with only 1
84  parallel process in order to test if the code also performs as expected
85  in multi-processing mode
86  """
87  cmd_params = ["basf2"]
88  if use_multi_processing:
89  cmd_params += ["-p1"]
90  cmd_params += [steering_file]
91  cmd_params += parameters
92 
93  return cmd_params
94 
95 
96 def available_revisions(work_folder: str) -> List[str]:
97  """
98  Loops over the results folder and looks for revisions. It then returns an
99  ordered list, with the most recent revision being the first element in the
100  list and the oldest revision being the last element.
101  The 'age' of a revision is determined by the 'Last-modified'-timestamp of
102  the corresponding folder.
103  :return: A list of all revisions available for plotting
104  """
105 
106  # Get all folders in ./results/ sorted descending by the date they were
107  # created (i.e. newest folder first)
108  search_folder = validationpath.get_results_folder(work_folder)
109  subfolders = [p for p in os.scandir(search_folder) if p.is_dir()]
110  revisions = [
111  p.name for p in sorted(subfolders, key=lambda p: p.stat().st_mtime)
112  ]
113  return revisions
114 
115 
116 def get_start_time() -> float:
117  """!
118  The function returns the value g_start_time which contain the start time
119  of the validation and is set just a few lines above.
120 
121  @return: Time since the validation has been started
122  """
123  return g_start_time
124 
125 
126 def get_validation_folders(
127  location: str, basepaths: Dict[str, str], log: logging.Logger
128 ) -> Dict[str, str]:
129  """!
130  Collects the validation folders for all packages from the stated release
131  directory (either local or central). Returns a dict with the following
132  form:
133  {'name of package':'absolute path to validation folder of package'}
134 
135  @param location: The location where we want to search for validation
136  folders (either 'local' or 'central')
137  """
138 
139  # Make sure we only look in existing locations:
140  if location not in ["local", "central"]:
141  return {}
142  if basepaths[location] is None:
143  return {}
144 
145  # Write to log what we are collecting
146  log.debug(f"Collecting {location} folders")
147 
148  # Reserve some memory for our results
149  results = {}
150 
151  # Now start collecting the folders.
152  # First, collect the general validation folders, because it needs special
153  # treatment (does not belong to any other package but may include
154  # steering files):
155  if os.path.isdir(basepaths[location] + "/validation"):
156  results["validation"] = basepaths[location] + "/validation"
157 
158  # get the special folder containing the validation tests
159  if os.path.isdir(basepaths[location] + "/validation/validation-test"):
160  results["validation-test"] = (
161  basepaths[location] + "/validation/validation-test"
162  )
163 
164  # Now get a list of all folders with name 'validation' which are
165  # subfolders of a folder (=package) in the release directory
166  package_dirs = glob.glob(
167  os.path.join(basepaths[location], "*", "validation")
168  )
169 
170  # Now loop over all these folders, find the name of the package they belong
171  # to and append them to our results dictionary
172  for package_dir in package_dirs:
173  package_name = os.path.basename(os.path.dirname(package_dir))
174  results[package_name] = package_dir
175 
176  # Return our results
177  return results
178 
179 
180 def get_argument_parser(
181  modes: Optional[List[str]] = None,
182 ) -> argparse.ArgumentParser:
183 
184  if not modes:
185  modes = ["local"]
186 
187  # Set up the command line parser
188  parser = argparse.ArgumentParser()
189 
190  # Define the accepted command line flags and read them in
191  parser.add_argument(
192  "-d",
193  "--dry",
194  help="Perform a dry run, i.e. run the validation module without "
195  "actually executing the steering files (for debugging purposes).",
196  action="store_true",
197  )
198  parser.add_argument(
199  "-m",
200  "--mode",
201  help="The mode which will be used for running the validation. "
202  "Possible values: " + ", ".join(modes) + ". Default is 'local'",
203  choices=modes,
204  type=str,
205  default="local",
206  )
207  parser.add_argument(
208  "-i",
209  "--intervals",
210  help="Comma seperated list of intervals for which to execute the "
211  "validation scripts. Default is 'nightly'",
212  type=str,
213  default="nightly",
214  )
215  parser.add_argument(
216  "-o",
217  "--options",
218  help="One or more strings that will be passed to basf2 as arguments. "
219  "Example: '-n 100'. Quotes are necessary!",
220  type=str,
221  nargs="+",
222  )
223  parser.add_argument(
224  "-p",
225  "--parallel",
226  help="The maximum number of parallel processes to run the "
227  "validation. Only used for local execution. Default is number "
228  "of CPU cores.",
229  type=int,
230  default=None,
231  )
232  parser.add_argument(
233  "-pkg",
234  "--packages",
235  help="The name(s) of one or multiple packages. Validation will be "
236  "run only on these packages! E.g. -pkg analysis arich",
237  type=str,
238  nargs="+",
239  )
240  parser.add_argument(
241  "-s",
242  "--select",
243  help="The file name(s) of one or more space separated validation "
244  "scripts that should be executed exclusively. All dependent "
245  "scripts will also be executed. E.g. -s ECL2D.C",
246  type=str,
247  nargs="+",
248  )
249  parser.add_argument(
250  "-si",
251  "--select-ignore-dependencies",
252  help="The file name of one or more space separated validation "
253  "scripts that should be executed exclusively. This will ignore "
254  "all dependencies. This is useful if you modified a script that "
255  "produces plots based on the output of its dependencies.",
256  type=str,
257  nargs="+",
258  )
259  parser.add_argument(
260  "--send-mails",
261  help="Send email to the contact persons who have failed comparison "
262  "plots. Mail is sent from b2soft@mail.desy.de via "
263  "/usr/sbin/sendmail.",
264  action="store_true",
265  )
266  parser.add_argument(
267  "--send-mails-mode",
268  help="How to send mails: Full report, incremental report (new/changed "
269  "warnings/failures only) or automatic (default; follow hard coded "
270  "rule, e.g. full reports every Monday).",
271  choices=["full", "incremental", "automatic"],
272  default="automatic",
273  )
274  parser.add_argument(
275  "-q", "--quiet", help="Suppress the progress bar", action="store_true"
276  )
277  parser.add_argument(
278  "-t",
279  "--tag",
280  help="The name that will be used for the current revision in the "
281  "results folder. Default is 'current'.",
282  type=str,
283  default="current",
284  )
285  parser.add_argument(
286  "--test",
287  help="Execute validation in testing mode where only the validation "
288  "scripts contained in the validation package are executed. "
289  "During regular validation, these scripts are ignored.",
290  action="store_true",
291  )
292  parser.add_argument(
293  "--use-cache",
294  help="If validation scripts are marked as cacheable and their output "
295  "files already exist, don't execute these scripts again",
296  action="store_true",
297  )
298  parser.add_argument(
299  "--view",
300  help="Once the validation is finished, start the local web server and "
301  "display the validation results in the system's default browser.",
302  action="store_true",
303  )
304  parser.add_argument(
305  "--max-run-time",
306  help="By default, running scripts (that is, steering files executed by"
307  "the validation framework) are terminated after a "
308  "certain time. Use this flag to change this setting by supplying "
309  "the maximal run time in minutes. Value <=0 disables the run "
310  "time upper limit entirely.",
311  type=int,
312  default=None,
313  )
314 
315  return parser
316 
317 
318 def parse_cmd_line_arguments(
319  modes: Optional[List[str]] = None,
320 ) -> argparse.Namespace:
321  """!
322  Sets up a parser for command line arguments, parses them and returns the
323  arguments.
324  @return: An object containing the parsed command line arguments.
325  Arguments are accessed like they are attributes of the object,
326  i.e. [name_of_object].[desired_argument]
327  """
328 
329  if not modes:
330  modes = ["local"]
331 
332  # Return the parsed arguments!
333  return get_argument_parser(modes).parse_args()
334 
335 
336 def scripts_in_dir(dirpath: str, log: logging.Logger, ext="*") -> List[str]:
337  """!
338  Returns all the files in the given dir (and its subdirs) that have
339  the extension 'ext', if an extension is given (default: all extensions)
340 
341  @param dirpath: The directory in which we are looking for files
342  @param log: logging.Logger object
343  @param ext: The extension of the files, which we are looking for.
344  '*' is the wildcard-operator (=all extensions are accepted)
345  @return: A sorted list of all files with the specified extension in the
346  given directory.
347  """
348 
349  # Write to log what we are collecting
350  log.debug(f"Collecting *{ext} files from {dirpath}")
351 
352  # Some space where we store our results before returning them
353  results = []
354 
355  # A list of all folder names that will be ignored (e.g. folders that are
356  # important for SCons
357  blacklist = [
358  "tools",
359  "scripts",
360  "examples",
361  validationpath.folder_name_html_static,
362  ]
363 
364  # Loop over the given directory and its subdirectories and find all files
365  for root, dirs, files in os.walk(dirpath):
366 
367  # Skip a directory if it is blacklisted
368  if os.path.basename(root) in blacklist:
369  continue
370 
371  # Loop over all files
372  for current_file in files:
373  # If the file has the requested extension, append its full paths to
374  # the results
375  if current_file.endswith(ext):
376  results.append(os.path.join(root, current_file))
377 
378  # Return our sorted results
379  return sorted(results)
380 
381 
382 def strip_ext(path: str) -> str:
383  """
384  Takes a path and returns only the name of the file, without the
385  extension on the file name
386  """
387  return os.path.splitext(os.path.split(path)[1])[0]
388 
389 
390 def get_style(index: Optional[int], overall_item_count=1):
391  """
392  Takes an index and returns the corresponding line attributes,
393  i.e. LineColor, LineWidth and LineStyle.
394  """
395 
396  # Define the colors for the plot
397  colors = [
398  ROOT.kRed,
399  ROOT.kOrange,
400  ROOT.kPink + 9,
401  ROOT.kOrange - 8,
402  ROOT.kGreen + 2,
403  ROOT.kCyan + 2,
404  ROOT.kBlue + 1,
405  ROOT.kRed + 2,
406  ROOT.kOrange + 3,
407  ROOT.kYellow + 2,
408  ROOT.kSpring,
409  ]
410 
411  # Define the linestyles for the plot
412  linestyles = {
413  "dashed": 2, # Dashed: - - - - -
414  "solid": 1, # Solid: ----------
415  "dashdot": 10,
416  } # Dash-dot: -?-?-?-
417  ls_index = {0: "dashed", 1: "solid", 2: "dashdot"}
418 
419  # Define the linewidth for the plots
420  linewidth = 2
421 
422  # make sure the index is set
423  if not index:
424  index = 0
425 
426  # Get the color for the (index)th revisions
427  color = colors[index % len(colors)]
428 
429  # Figure out the linestyle
430  # If there is only one revision, make it solid!
431  # It cannot overlap with any other line
432  if overall_item_count == 1:
433  linestyle = linestyles["solid"]
434  # Otherwise make sure the newest revision (which is drawn on top) gets a
435  # dashed linestyle
436  else:
437  linestyle = linestyles[ls_index[index % len(ls_index)]]
438 
439  return ROOT.TAttLine(color, linestyle, linewidth)
440 
441 
442 def index_from_revision(revision: str, work_folder: str) -> Optional[int]:
443  """
444  Takes the name of a revision and returns the corresponding index. Indices
445  are used to ensure that the color and style of a revision in a plot are
446  always the same, regardless of the displayed revisions.
447  Example: release-X is always red, and no other release get drawn in red if
448  release-X is not selected for display.
449  :param revision: A string containing the name of a revision
450  :param work_folder: The work folder containing the results and plots
451  :return: The index of the requested revision, or None, if no index could
452  be found for 'revision'
453  """
454 
455  revisions = available_revisions(work_folder) + ["reference"]
456 
457  if revision in revisions:
458  return revisions.index(revision)
459  else:
460  return None
461 
462 
463 def get_log_file_paths(logger: logging.Logger) -> List[str]:
464  """
465  Returns list of paths that the FileHandlers of logger write to.
466  :param logger: logging.logger object.
467  :return: List of paths
468  """
469  ret = []
470  for handler in logger.handlers:
471  try:
472  ret.append(handler.baseFilename)
473  except AttributeError:
474  pass
475  return ret
476 
477 
478 def get_terminal_width() -> int:
479  """
480  Returns width of terminal in characters, or 80 if unknown.
481 
482  Copied from basf2 utils. However, we only compile the validation package
483  on b2master, so copy this here.
484  """
485  from shutil import get_terminal_size
486 
487  return get_terminal_size(fallback=(80, 24)).columns
488 
489 
490 def congratulator(
491  success: Optional[Union[int, float]] = None,
492  failure: Optional[Union[int, float]] = None,
493  total: Optional[Union[int, float]] = None,
494  just_comment=False,
495  rate_name="Success rate",
496 ) -> str:
497  """ Keeping the morale up by commenting on success rates.
498 
499  Args:
500  success: Number of successes
501  failure: Number of failures
502  total: success + failures (out of success, failure and total, exactly
503  2 have to be spefified. If you want to use your own figure of
504  merit, just set total = 1. and set success to a number between 0.0
505  (infernal) to 1.0 (stellar))
506  just_comment: Do not add calculated percentage to return string.
507  rate_name: How to refer to the calculated success rate.
508 
509  Returns:
510  Comment on your success rate (str).
511  """
512 
513  n_nones = [success, failure, total].count(None)
514 
515  if n_nones == 0 and total != success + failure:
516  print(
517  "ERROR (congratulator): Specify 2 of the arguments 'success',"
518  "'failure', 'total'.",
519  file=sys.stderr,
520  )
521  return ""
522  elif n_nones >= 2:
523  print(
524  "ERROR (congratulator): Specify 2 of the arguments 'success',"
525  "'failure', 'total'.",
526  file=sys.stderr,
527  )
528  return ""
529  else:
530  if total is None:
531  total = success + failure
532  if failure is None:
533  failure = total - success
534  if success is None:
535  success = total - failure
536 
537  # Beware of zero division errors.
538  if total == 0:
539  return "That wasn't really exciting, was it?"
540 
541  success_rate = 100 * success / total
542 
543  comments = {
544  00.0: "You're grounded!",
545  10.0: "Infernal...",
546  20.0: "That's terrible!",
547  40.0: "You can do better than that.",
548  50.0: "That still requires some work.",
549  75.0: "Three quarters! Almost there!",
550  80.0: "Way to go ;)",
551  90.0: "Gold medal!",
552  95.0: "Legendary!",
553  99.0: "Nobel price!",
554  99.9: "Godlike!",
555  }
556 
557  for value in sorted(comments.keys(), reverse=True):
558  if success_rate >= value:
559  comment = comments[value]
560  break
561  else:
562  # below minimum?
563  comment = comments[0]
564 
565  if just_comment:
566  return comment
567  else:
568  return "{} {}%. {}".format(rate_name, int(success_rate), comment)
569 
570 
571 def terminal_title_line(title="", subtitle="", level=0) -> str:
572  """ Print a title line in the terminal.
573 
574  Args:
575  title (str): The title. If no title is given, only a separating line
576  is printed.
577  subtitle (str): Subtitle.
578  level (int): The lower, the more dominantly the line will be styled.
579  """
580  linewidth = get_terminal_width()
581 
582  # using the markdown title underlining chars for lack of better
583  # alternatives
584  char_dict = {0: "=", 1: "-", 2: "~"}
585 
586  for key in sorted(char_dict.keys(), reverse=True):
587  if level >= key:
588  char = char_dict[key]
589  break
590  else:
591  # below minimum, shouldn't happen but anyway
592  char = char_dict[0]
593 
594  line = char * linewidth
595  if not title:
596  return line
597 
598  # guess we could make a bit more effort with indenting/handling long titles
599  # capitalization etc., but for now:
600  ret = line + "\n"
601  ret += title.capitalize() + "\n"
602  if subtitle:
603  ret += subtitle + "\n"
604  ret += line
605  return ret
606 
607 
608 def get_file_metadata(filename: str) -> str:
609  """
610  Retrieve the metadata for a file using ``b2file-metadata-show -a``.
611 
612  Args:
613  metadata (str): File to get number of events from.
614 
615  Returns:
616  (str): Metadata of file.
617  """
618  if not Path(filename).exists():
619  raise FileNotFoundError(f"Could not find file {filename}")
620 
621  proc = subprocess.run(
622  ["b2file-metadata-show", "-a", str(filename)],
623  stdout=subprocess.PIPE,
624  check=True,
625  )
626  metadata = proc.stdout.decode("utf-8")
627  return metadata
def get_results_folder(output_base_dir)