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