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')
268 if location
not in [
"local",
"central"]:
270 if basepaths[location]
is None:
274 log.debug(f
"Collecting {location} folders")
283 if os.path.isdir(basepaths[location] +
"/validation"):
284 results[
"validation"] = basepaths[location] +
"/validation"
287 if os.path.isdir(basepaths[location] +
"/validation/validation-test"):
288 results[
"validation-test"] = (
289 basepaths[location] +
"/validation/validation-test"
294 package_dirs = glob.glob(
295 os.path.join(basepaths[location],
"*",
"validation")
300 for package_dir
in package_dirs:
301 package_name = os.path.basename(os.path.dirname(package_dir))
302 results[package_name] = package_dir
308def get_argument_parser(
309 modes: Optional[List[str]] =
None,
310) -> argparse.ArgumentParser:
316 parser = argparse.ArgumentParser()
322 help=
"Perform a dry run, i.e. run the validation module without "
323 "actually executing the steering files (for debugging purposes).",
329 help=
"The mode which will be used for running the validation. "
330 "Possible values: " +
", ".join(modes) +
". Default is 'local'",
338 help=
"Comma separated list of intervals for which to execute the "
339 "validation scripts. Default is 'nightly'",
346 help=
"One or more strings that will be passed to basf2 as arguments. "
347 "Example: '-n 100'. Quotes are necessary!",
354 help=
"The maximum number of parallel processes to run the "
355 "validation. Only used for local execution. Default is number "
363 help=
"The name(s) of one or multiple packages. Validation will be "
364 "run only on these packages! E.g. -pkg analysis arich",
371 help=
"The file name(s) of one or more space separated validation "
372 "scripts that should be executed exclusively. All dependent "
373 "scripts will also be executed. E.g. -s ECL2D.C "
374 "(use -si instead to execute script(s) ignoring dependencies)",
380 "--select-ignore-dependencies",
381 help=
"The file name of one or more space separated validation "
382 "scripts that should be executed exclusively. This will ignore "
383 "all dependencies. This is useful if you modified a script that "
384 "produces plots based on the output of its dependencies.",
390 help=
"Send email to the contact persons who have failed comparison "
391 "plots. Mail is sent from b2soft@mail.desy.de via "
392 "/usr/sbin/sendmail.",
397 help=
"How to send mails: Full report, incremental report (new/changed "
398 "warnings/failures only) or automatic (default; follow hard coded "
399 "rule, e.g. full reports every Monday).",
400 choices=[
"full",
"incremental",
"automatic"],
404 "-q",
"--quiet", help=
"Suppress the progress bar", action=
"store_true"
409 help=
"The name that will be used for the current revision in the "
410 "results folder. Default is 'current'.",
416 help=
"Execute validation in testing mode where only the validation "
417 "scripts contained in the validation package are executed. "
418 "During regular validation, these scripts are ignored.",
423 help=
"If validation scripts are marked as cacheable and their output "
424 "files already exist, don't execute these scripts again",
429 help=
"Once the validation is finished, start the local web server and "
430 "display the validation results in the system's default browser.",
435 help=
"By default, running scripts (that is, steering files executed by"
436 "the validation framework) are terminated after a "
437 "certain time. Use this flag to change this setting by supplying "
438 "the maximal run time in minutes. Value <=0 disables the run "
439 "time upper limit entirely.",
447def parse_cmd_line_arguments(
448 modes: Optional[List[str]] =
None,
449) -> argparse.Namespace:
451 Sets up a parser for command line arguments, parses them and returns the
453 @return: An object containing the parsed command line arguments.
454 Arguments are accessed like they are attributes of the object,
455 i.e. [name_of_object].[desired_argument]
462 return get_argument_parser(modes).parse_args()
465def scripts_in_dir(dirpath: str, log: logging.Logger, ext=
"*") -> List[str]:
467 Returns all the files in the given dir (and its subdirs) that have
468 the extension 'ext', if an extension is given (default: all extensions)
470 @param dirpath: The directory in which we are looking for files
471 @param log: logging.Logger object
472 @param ext: The extension of the files, which we are looking for.
473 '*' is the wildcard-operator (=all extensions are accepted)
474 @return: A sorted list of all files with the specified extension in the
479 log.debug(f
"Collecting *{ext} files from {dirpath}")
490 validationpath.folder_name_html_static,
494 for root, dirs, files
in os.walk(dirpath):
497 if os.path.basename(root)
in blacklist:
501 for current_file
in files:
504 if current_file.endswith(ext):
505 results.append(os.path.join(root, current_file))
508 return sorted(results)
511def strip_ext(path: str) -> str:
513 Takes a path and returns only the name of the file, without the
514 extension on the file name
516 return os.path.splitext(os.path.split(path)[1])[0]
519def get_style(index: Optional[int], overall_item_count=1):
521 Takes an index and returns the corresponding line attributes,
522 i.e. LineColor, LineWidth and LineStyle.
546 ls_index = {0:
"dashed", 1:
"solid", 2:
"dashdot"}
556 color = colors[index % len(colors)]
561 if overall_item_count == 1:
562 linestyle = linestyles[
"solid"]
566 linestyle = linestyles[ls_index[index % len(ls_index)]]
568 return ROOT.TAttLine(color, linestyle, linewidth)
571def index_from_revision(revision: str, work_folder: str) -> Optional[int]:
573 Takes the name of a revision and returns the corresponding index. Indices
574 are used to ensure that the color and style of a revision in a plot are
575 always the same, regardless of the displayed revisions.
576 Example: release-X is always red, and no other release get drawn in red if
577 release-X is not selected for display.
578 :param revision: A string containing the name of a revision
579 :param work_folder: The work folder containing the results and plots
580 :return: The index of the requested revision, or None, if no index could
581 be found for 'revision'
584 revisions = available_revisions(work_folder) + [
"reference"]
586 if revision
in revisions:
587 return revisions.index(revision)
592def get_log_file_paths(logger: logging.Logger) -> List[str]:
594 Returns list of paths that the FileHandlers of logger write to.
595 :param logger: logging.logger object.
596 :return: List of paths
599 for handler
in logger.handlers:
601 ret.append(handler.baseFilename)
602 except AttributeError:
607def get_terminal_width() -> int:
609 Returns width of terminal in characters, or 80 if unknown.
611 Copied from basf2 utils. However, we only compile the validation package
612 on b2master, so copy this here.
614 from shutil
import get_terminal_size
616 return get_terminal_size(fallback=(80, 24)).columns
620 success: Optional[Union[int, float]] =
None,
621 failure: Optional[Union[int, float]] =
None,
622 total: Optional[Union[int, float]] =
None,
624 rate_name=
"Success rate",
626 """ Keeping the morale up by commenting on success rates.
629 success: Number of successes
630 failure: Number of failures
631 total: success + failures (out of success, failure and total, exactly
632 2 have to be specified. If you want to use your own figure of
633 merit, just set total = 1. and set success to a number between 0.0
634 (infernal) to 1.0 (stellar))
635 just_comment: Do not add calculated percentage to return string.
636 rate_name: How to refer to the calculated success rate.
639 Comment on your success rate (str).
642 n_nones = [success, failure, total].count(
None)
644 if n_nones == 0
and total != success + failure:
646 "ERROR (congratulator): Specify 2 of the arguments 'success',"
647 "'failure', 'total'.",
653 "ERROR (congratulator): Specify 2 of the arguments 'success',"
654 "'failure', 'total'.",
660 total = success + failure
662 failure = total - success
664 success = total - failure
668 return "That wasn't really exciting, was it?"
670 success_rate = 100 * success / total
673 00.0:
"You're grounded!",
675 20.0:
"That's terrible!",
676 40.0:
"You can do better than that.",
677 50.0:
"That still requires some work.",
678 75.0:
"Three quarters! Almost there!",
679 80.0:
"Way to go ;)",
682 99.0:
"Nobel price!",
686 for value
in sorted(comments.keys(), reverse=
True):
687 if success_rate >= value:
688 comment = comments[value]
692 comment = comments[0]
697 return f
"{rate_name} {int(success_rate)}%. {comment}"
700def terminal_title_line(title="", subtitle="", level=0) -> str:
701 """ Print a title line in the terminal.
704 title (str): The title. If no title is given, only a separating line
706 subtitle (str): Subtitle.
707 level (int): The lower, the more dominantly the line will be styled.
709 linewidth = get_terminal_width()
713 char_dict = {0:
"=", 1:
"-", 2:
"~"}
715 for key
in sorted(char_dict.keys(), reverse=
True):
717 char = char_dict[key]
723 line = char * linewidth
730 ret += title.capitalize() +
"\n"
732 ret += subtitle +
"\n"
737def get_file_metadata(filename: str) -> str:
739 Retrieve the metadata for a file using ``b2file-metadata-show -a``.
742 metadata (str): File to get number of events from.
745 (str): Metadata of file.
747 if not Path(filename).exists():
748 raise FileNotFoundError(f
"Could not find file {filename}")
753 proc = subprocess.run(
754 [
"b2file-metadata-show",
"-a", str(filename)],
755 stdout=subprocess.PIPE,
758 metadata = proc.stdout.decode(
"utf-8")
759 except subprocess.CalledProcessError
as e:
get_results_folder(output_base_dir)