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