15g_start_time = timeit.default_timer()
24from typing
import Dict, Optional, List, Union
26from pathlib
import Path
41def get_timezone() -> str:
43 Returns the correct timezone as short string
45 tz_tuple = time.tzname
49 if time.daylight != 0:
55def get_compact_git_hash(repo_folder: str) -> Optional[str]:
57 Returns the compact git hash from a folder inside of a git repository
61 subprocess.check_output(
62 [
"git",
"show",
"--oneline",
"-s"], cwd=repo_folder
68 cmd_output = cmd_output.split(
" ")
69 if len(cmd_output) > 1:
74 except subprocess.CalledProcessError:
78def basf2_command_builder(
79 steering_file: str, parameters: List[str], use_multi_processing=
False
82 This utility function takes the steering file name and other basf2
83 parameters and returns a list which can be executed via the OS shell for
84 example to subprocess.Popen(params ...) If use_multi_processing is True,
85 the script will be executed in multi-processing mode with only 1
86 parallel process in order to test if the code also performs as expected
87 in multi-processing mode
89 cmd_params = [
"basf2"]
90 if use_multi_processing:
92 cmd_params += [steering_file]
93 cmd_params += parameters
98def available_revisions(work_folder: str) -> List[str]:
100 Loops over the results folder and looks for revisions. It then returns an
101 ordered list, with the most recent revision being the first element in the
102 list and the oldest revision being the last element.
103 The 'age' of a revision is determined by the 'Last-modified'-timestamp of
104 the corresponding folder.
105 :return: A list of all revisions available for plotting
111 subfolders = [p
for p
in os.scandir(search_folder)
if p.is_dir()]
113 p.name
for p
in sorted(subfolders, key=
lambda p: p.stat().st_mtime)
118def get_latest_nightly(work_folder: str) -> str:
120 Loops over the results folder and looks for nightly builds. It then returns
121 the most recent nightly tag sorted by date in the name. If no
122 nightly results are available then it returns the default 'current' tag.
123 :return: the most recent nightly build or current
125 available = available_revisions(work_folder)
126 available_nightlies = [
127 revision
for revision
in available
128 if revision.startswith(
"nightly")
130 if available_nightlies:
131 return sorted(available_nightlies, reverse=
True)[0]
136def get_popular_revision_combinations(work_folder: str) -> List[str]:
138 Returns several combinations of available revisions that we might
139 want to pre-build on the server.
142 List[List of revisions (str)]
145 available_revisions(work_folder),
148 available_releases = [
149 revision
for revision
in available
150 if revision.startswith(
"release")
or revision.startswith(
"prerelease")
152 available_nightlies = [
153 revision
for revision
in available
154 if revision.startswith(
"nightly")
157 def atindex_or_none(lst, index):
158 """ Returns item at index from lst or None"""
164 def remove_duplicates_lstlst(lstlst):
165 """ Removes duplicate lists in a list of lists """
180 [
"reference"] + sorted(available),
183 [
"reference", atindex_or_none(available_releases, 0)],
184 [
"reference", atindex_or_none(available_nightlies, 0)],
187 [
"reference"] + sorted(list(filter(
190 atindex_or_none(available_releases, 0),
191 atindex_or_none(available_nightlies, 0)
196 [
"reference"] + sorted(available_nightlies)
201 list(filter(
None, comb))
for comb
in ret
204 ret = list(filter(
None, ret))
207 ret = remove_duplicates_lstlst(ret)
210 sys.exit(
"No revisions seem to be available. Exit.")
215def clear_plots(work_folder: str, keep_revisions: List[str]):
217 This function will clear the plots folder to get rid of all but the
218 skipped revisions' associated plot files.
221 rainbow_file = os.path.join(work_folder,
'rainbow.json')
224 keep_revisions = [sorted(revs)
for revs
in keep_revisions]
226 with open(rainbow_file)
as rainbow:
227 entries = json.loads(rainbow.read())
228 for hash, revisions
in entries.items():
230 if sorted(revisions)
in keep_revisions:
231 print(f
'Retaining {hash}')
232 cleaned_rainbow[hash] = revisions
235 print(f
'Removing {hash}:{revisions}')
236 work_folder_path = Path(os.path.join(work_folder, hash))
237 if work_folder_path.exists()
and work_folder_path.is_dir():
238 shutil.rmtree(work_folder_path)
240 with open(rainbow_file,
'w')
as rainbow:
241 rainbow.write(json.dumps(cleaned_rainbow, indent=4))
244def get_start_time() -> float:
246 The function returns the value g_start_time which contain the start time
247 of the validation and is set just a few lines above.
249 @return: Time since the validation has been started
254def get_validation_folders(
255 location: str, basepaths: Dict[str, str], log: logging.Logger
258 Collects the validation folders for all packages from the stated release
259 directory (either local or central). Returns a dict with the following
261 {'name of package':'absolute path to validation folder of package'}
263 @param location: The location where we want to search for validation
264 folders (either 'local' or 'central')
265 @param basepaths: The dictionary with base paths of local and release directory
266 @param log: The logging dictionary
270 if location
not in [
"local",
"central"]:
272 if basepaths[location]
is None:
276 log.debug(f
"Collecting {location} folders")
285 if os.path.isdir(basepaths[location] +
"/validation"):
286 results[
"validation"] = basepaths[location] +
"/validation"
289 if os.path.isdir(basepaths[location] +
"/validation/validation-test"):
290 results[
"validation-test"] = (
291 basepaths[location] +
"/validation/validation-test"
296 package_dirs = glob.glob(
297 os.path.join(basepaths[location],
"*",
"validation")
302 for package_dir
in package_dirs:
303 package_name = os.path.basename(os.path.dirname(package_dir))
304 results[package_name] = package_dir
310def get_argument_parser(
311 modes: Optional[List[str]] =
None,
312) -> argparse.ArgumentParser:
318 parser = argparse.ArgumentParser()
324 help=
"Perform a dry run, i.e. run the validation module without "
325 "actually executing the steering files (for debugging purposes).",
331 help=
"The mode which will be used for running the validation. "
332 "Possible values: " +
", ".join(modes) +
". Default is 'local'",
340 help=
"Comma separated list of intervals for which to execute the "
341 "validation scripts. Default is 'nightly'",
348 help=
"One or more strings that will be passed to basf2 as arguments. "
349 "Example: '-n 100'. Quotes are necessary!",
356 help=
"The maximum number of parallel processes to run the "
357 "validation. Only used for local execution. Default is number "
365 help=
"The name(s) of one or multiple packages. Validation will be "
366 "run only on these packages! E.g. -pkg analysis arich",
373 help=
"The file name(s) of one or more space separated validation "
374 "scripts that should be executed exclusively. All dependent "
375 "scripts will also be executed. E.g. -s ECL2D.C "
376 "(use -si instead to execute script(s) ignoring dependencies)",
382 "--select-ignore-dependencies",
383 help=
"The file name of one or more space separated validation "
384 "scripts that should be executed exclusively. This will ignore "
385 "all dependencies. This is useful if you modified a script that "
386 "produces plots based on the output of its dependencies.",
392 help=
"Send email to the contact persons who have failed comparison "
393 "plots. Mail is sent from b2soft@mail.desy.de via "
394 "/usr/sbin/sendmail.",
399 help=
"How to send mails: Full report, incremental report (new/changed "
400 "warnings/failures only) or automatic (default; follow hard coded "
401 "rule, e.g. full reports every Monday).",
402 choices=[
"full",
"incremental",
"automatic"],
406 "-q",
"--quiet", help=
"Suppress the progress bar", action=
"store_true"
411 help=
"The name that will be used for the current revision in the "
412 "results folder. Default is 'current'.",
418 help=
"Execute validation in testing mode where only the validation "
419 "scripts contained in the validation package are executed. "
420 "During regular validation, these scripts are ignored.",
425 help=
"If validation scripts are marked as cacheable and their output "
426 "files already exist, don't execute these scripts again",
431 help=
"Once the validation is finished, start the local web server and "
432 "display the validation results in the system's default browser.",
437 help=
"By default, running scripts (that is, steering files executed by"
438 "the validation framework) are terminated after a "
439 "certain time. Use this flag to change this setting by supplying "
440 "the maximal run time in minutes. Value <=0 disables the run "
441 "time upper limit entirely.",
449def parse_cmd_line_arguments(
450 modes: Optional[List[str]] =
None,
451) -> argparse.Namespace:
453 Sets up a parser for command line arguments, parses them and returns the
455 @return: An object containing the parsed command line arguments.
456 Arguments are accessed like they are attributes of the object,
457 i.e. [name_of_object].[desired_argument]
464 return get_argument_parser(modes).parse_args()
467def scripts_in_dir(dirpath: str, log: logging.Logger, ext=
"*") -> List[str]:
469 Returns all the files in the given dir (and its subdirs) that have
470 the extension 'ext', if an extension is given (default: all extensions)
472 @param dirpath: The directory in which we are looking for files
473 @param log: logging.Logger object
474 @param ext: The extension of the files, which we are looking for.
475 '*' is the wildcard-operator (=all extensions are accepted)
476 @return: A sorted list of all files with the specified extension in the
481 log.debug(f
"Collecting *{ext} files from {dirpath}")
492 validationpath.folder_name_html_static,
496 for root, dirs, files
in os.walk(dirpath):
499 if os.path.basename(root)
in blacklist:
503 for current_file
in files:
506 if current_file.endswith(ext):
507 results.append(os.path.join(root, current_file))
510 return sorted(results)
513def strip_ext(path: str) -> str:
515 Takes a path and returns only the name of the file, without the
516 extension on the file name
518 return os.path.splitext(os.path.split(path)[1])[0]
521def get_style(index: Optional[int], overall_item_count=1):
523 Takes an index and returns the corresponding line attributes,
524 i.e. LineColor, LineWidth and LineStyle.
548 ls_index = {0:
"dashed", 1:
"solid", 2:
"dashdot"}
558 color = colors[index % len(colors)]
563 if overall_item_count == 1:
564 linestyle = linestyles[
"solid"]
568 linestyle = linestyles[ls_index[index % len(ls_index)]]
570 return ROOT.TAttLine(color, linestyle, linewidth)
573def index_from_revision(revision: str, work_folder: str) -> Optional[int]:
575 Takes the name of a revision and returns the corresponding index. Indices
576 are used to ensure that the color and style of a revision in a plot are
577 always the same, regardless of the displayed revisions.
578 Example: release-X is always red, and no other release get drawn in red if
579 release-X is not selected for display.
580 :param revision: A string containing the name of a revision
581 :param work_folder: The work folder containing the results and plots
582 :return: The index of the requested revision, or None, if no index could
583 be found for 'revision'
586 revisions = available_revisions(work_folder) + [
"reference"]
588 if revision
in revisions:
589 return revisions.index(revision)
594def get_log_file_paths(logger: logging.Logger) -> List[str]:
596 Returns list of paths that the FileHandlers of logger write to.
597 :param logger: logging.logger object.
598 :return: List of paths
601 for handler
in logger.handlers:
603 ret.append(handler.baseFilename)
604 except AttributeError:
609def get_terminal_width() -> int:
611 Returns width of terminal in characters, or 80 if unknown.
613 Copied from basf2 utils. However, we only compile the validation package
614 on b2master, so copy this here.
616 from shutil
import get_terminal_size
618 return get_terminal_size(fallback=(80, 24)).columns
622 success: Optional[Union[int, float]] =
None,
623 failure: Optional[Union[int, float]] =
None,
624 total: Optional[Union[int, float]] =
None,
626 rate_name=
"Success rate",
628 """ Keeping the morale up by commenting on success rates.
631 success: Number of successes
632 failure: Number of failures
633 total: success + failures (out of success, failure and total, exactly
634 2 have to be specified. If you want to use your own figure of
635 merit, just set total = 1. and set success to a number between 0.0
636 (infernal) to 1.0 (stellar))
637 just_comment: Do not add calculated percentage to return string.
638 rate_name: How to refer to the calculated success rate.
641 Comment on your success rate (str).
644 n_nones = [success, failure, total].count(
None)
646 if n_nones == 0
and total != success + failure:
648 "ERROR (congratulator): Specify 2 of the arguments 'success',"
649 "'failure', 'total'.",
655 "ERROR (congratulator): Specify 2 of the arguments 'success',"
656 "'failure', 'total'.",
662 total = success + failure
664 failure = total - success
666 success = total - failure
670 return "That wasn't really exciting, was it?"
672 success_rate = 100 * success / total
675 00.0:
"You're grounded!",
677 20.0:
"That's terrible!",
678 40.0:
"You can do better than that.",
679 50.0:
"That still requires some work.",
680 75.0:
"Three quarters! Almost there!",
681 80.0:
"Way to go ;)",
684 99.0:
"Nobel price!",
688 for value
in sorted(comments.keys(), reverse=
True):
689 if success_rate >= value:
690 comment = comments[value]
694 comment = comments[0]
699 return f
"{rate_name} {int(success_rate)}%. {comment}"
702def terminal_title_line(title="", subtitle="", level=0) -> str:
703 """ Print a title line in the terminal.
706 title (str): The title. If no title is given, only a separating line
708 subtitle (str): Subtitle.
709 level (int): The lower, the more dominantly the line will be styled.
711 linewidth = get_terminal_width()
715 char_dict = {0:
"=", 1:
"-", 2:
"~"}
717 for key
in sorted(char_dict.keys(), reverse=
True):
719 char = char_dict[key]
725 line = char * linewidth
732 ret += title.capitalize() +
"\n"
734 ret += subtitle +
"\n"
739def get_file_metadata(filename: str) -> str:
741 Retrieve the metadata for a file using ``b2file-metadata-show -a``.
744 metadata (str): File to get number of events from.
747 (str): Metadata of file.
749 if not Path(filename).exists():
750 raise FileNotFoundError(f
"Could not find file {filename}")
755 proc = subprocess.run(
756 [
"b2file-metadata-show",
"-a", str(filename)],
757 stdout=subprocess.PIPE,
760 metadata = proc.stdout.decode(
"utf-8")
761 except subprocess.CalledProcessError
as e:
get_results_folder(output_base_dir)