Belle II Software development
validationserver.py
1
9from typing import Dict, Any, List, Tuple
10from glob import glob
11import json
12import functools
13import time
14import datetime
15from multiprocessing import Process, Queue
16import os.path
17import argparse
18import logging
19import sys
20import queue
21import webbrowser
22import re
23import collections
24import configparser
25import requests
26
27# 3rd
28import cherrypy
29import gitlab
30
31# ours
32import json_objects
33from validationplots import create_plots
34import validationfunctions
35import validationpath
36
37g_plottingProcesses: Dict[str, Tuple[Process, Queue, Dict[str, Any]]] = ({})
38
39
40def get_revision_label_from_json_filename(json_filename: str) -> str:
41 """
42 Gets the label of a revision from the path to the revision.json file
43 for example results/r121/revision.json
44 will result in the label r121
45 This is useful if the results folder has been moved by the user
46 """
47 folder_part = os.path.split(json_filename)[0]
48 last_folder = os.path.basename(folder_part)
49
50 return last_folder
51
52
53def get_json_object_list(
54 results_folder: str, json_file_name: str
55) -> List[str]:
56 """
57 Searches one folder's sub-folder for json files of a specific name and returns a combined list of the
58 json file's content
59 """
60
61 search_string = results_folder + "/*/" + json_file_name
62
63 found_revs = glob(search_string)
64 found_rev_labels = []
65
66 for r_file in found_revs:
67 # try loading json file
68 with open(r_file) as json_file:
69 data = json.load(json_file) # noqa
70
71 # always use the folder name as label
72 found_rev_labels.append(
73 get_revision_label_from_json_filename(r_file)
74 )
75
76 return found_rev_labels
77
78
79def deliver_json(file_name: str):
80 """
81 Simply load & parse a json file and return the
82 python objects
83 """
84
85 with open(file_name) as json_file:
86 data = json.load(json_file)
87 return data
88
89
90def create_revision_key(revision_names: List[str]) -> str:
91 """
92 Create a string key out of a revision list, which is handed to tho browser
93 in form of a progress key
94 """
95 return functools.reduce(lambda x, y: x + "-" + y, revision_names, "")
96
97
98def check_plotting_status(progress_key: str):
99 """
100 Check the plotting status via the supplied progress_key
101 """
102
103 if progress_key not in g_plottingProcesses:
104 return None
105
106 process, qu, last_status = g_plottingProcesses[progress_key]
107
108 # read latest message
109 try:
110 # read as much entries from the queue as possible
111 while not qu.empty():
112 msg = qu.get_nowait()
113 last_status = msg
114
115 # update the last status
116 g_plottingProcesses[progress_key] = (process, qu, last_status)
117 except queue.Empty:
118 pass
119
120 return last_status
121
122
123# todo: remove this, once we're certain that the bug was fixed!
124def warn_wrong_directory():
125 if not os.getcwd().endswith("html"):
126 print(
127 f"ERROR: Expected to be in HTML directory, but my current "
128 f"working directory is {os.getcwd()}; abspath: {os.getcwd()}."
129 )
130
131
132# todo: limit the number of running plotting requests & terminate hanging ones
133def start_plotting_request(
134 revision_names: List[str], results_folder: str
135) -> str:
136 """
137 Start a new comparison between the supplied revisions
138
139 Returns:
140 revision key
141 """
142
143 rev_key = create_revision_key(revision_names)
144
145 # still running a plotting for this combination ?
146 if rev_key in g_plottingProcesses:
147 logging.info(f"Plotting request for {rev_key} still running")
148 return rev_key
149
150 # create queue to stream progress, only one directional from parent to
151 # child
152 qu = Queue()
153
154 # start a new process for creating the plots
155 p = Process(
156 target=create_plots,
157 args=(
158 revision_names,
159 False,
160 qu,
161 # go one folder up, because this function
162 # expects the work dir, which contains
163 # the results folder
164 os.path.dirname(results_folder),
165 ),
166 )
167 p.start()
168 g_plottingProcesses[rev_key] = (p, qu, None)
169
170 logging.info(f"Started process for plotting request {rev_key}")
171
172 return rev_key
173
174
175"""
176Gitlab Integration
177
178Under here are functions that enable the validationserver to
179interact directly with the Gitlab project page to track and
180update issues.
181
182Requirement:
183Config file with project information and access token in the local
184machine. This is expected to be in the validation/config folder, in
185the same root directory as the html_static files.
186
187A check is performed to see if the config file exists with all the
188relevant details and all of Gitlab functionalities are enabled/disabled
189accordingly.
190
191When the server is being set up, a Gitlab object is created, which
192will be used subsequently to make all the API calls.
193
194As a final server initialization step, the project is queried to
195check if any of the current results are linked to existing issues
196and the result files are updated accordingly.
197
198The create/update issue functionality is accessible from the plot
199container. All the relevant pages are part of the
200validationserver cherry object.
201
202Issues created by the validation server will contain a block of
203automated code at the end of description and can be easily
204filtered from the GitLab issues page using the search string
205"Automated code, please do not delete". Relevant plots will be
206listed as a note in the issue page, along with the revision label.
207
208Function to upload files to Gitlab helps with pushing error plots
209to the project.
210"""
211
212
213def get_gitlab_config(
214 config_path: str
215) -> configparser.ConfigParser:
216 """
217 Parse the configuration file to be used to authenticate
218 GitLab API and retrieve relevant project info.
219
220 Returns:
221 gitlab configparser object
222 """
223
224 gitlab_config = configparser.ConfigParser()
225 gitlab_config.read(config_path)
226
227 return gitlab_config
228
229
230def create_gitlab_object(config_path: str) -> gitlab.Gitlab:
231 """
232 Establish connection with Gitlab using a private access key and return
233 a Gitlab object that can be used to make API calls. Default config
234 from the passed ini file will be used.
235
236 Returns:
237 gitlab object
238 """
239
240 gitlab_object = gitlab.Gitlab.from_config(
241 config_files=[config_path]
242 )
243 try:
244 gitlab_object.auth()
245 logging.info("Established connection with Gitlab")
246 except gitlab.exceptions.GitlabAuthenticationError:
247 gitlab_object = None
248 logging.warning(
249 "Issue with authenticating GitLab. "
250 "Please ensure access token is correct and valid. "
251 "GitLab Integration will be disabled."
252 )
253 except requests.exceptions.Timeout:
254 gitlab_object = None
255 logging.warning(
256 "GitLab servers feeling under the weather, DESY outage? "
257 "GitLab Integration will be disabled."
258 )
259
260 return gitlab_object
261
262
263def get_project_object(
264 gitlab_object: gitlab.Gitlab, project_id: str
265) -> 'gitlab.project':
266 """
267 Fetch Gitlab project associated with the project ID.
268
269 Returns:
270 gitlab project object
271 """
272
273 project = gitlab_object.projects.get(project_id, lazy=True)
274
275 return project
276
277
278def search_project_issues(
279 gitlab_object: gitlab.Gitlab,
280 search_term: str,
281 state: str = 'opened',
282) -> 'list[gitlab.issues]':
283 """
284 Search in the Gitlab for open issues that contain the
285 key phrase.
286
287 Returns:
288 gitlab project issues
289 """
290
291 issues = gitlab_object.issues.list(
292 search=search_term,
293 state=state,
294 lazy=True,
295 scope='all',
296 get_all=True,
297 )
298
299 return issues
300
301
302def update_linked_issues(
303 gitlab_object: gitlab.Gitlab, cwd_folder: str
304) -> None:
305 """
306 Fetch linked issues and update the comparison json files.
307
308 Returns:
309 None
310 """
311
312 # collect list of issues validation server has worked with
313 search_key = "Automated code, please do not delete"
314 issues = search_project_issues(gitlab_object, search_key)
315 past_issues = search_project_issues(gitlab_object, search_key, 'closed')
316
317 # find out the plots/scripts linked to the issues
318 # store closed issue ids as -ve numbers to distinguish them from open ones
319 plot_issues = collections.defaultdict(list)
320 script_issues = collections.defaultdict(list)
321 pattern = r"Relevant ([a-z]+): (\w+.*\w*)"
322 for i, issue in enumerate(issues+past_issues):
323 match = re.search(pattern, issue.description)
324 if match:
325 if match.groups()[0] == 'plot':
326 if i >= len(issues):
327 plot_issues[match.groups()[1]].append(-issue.iid)
328 else:
329 plot_issues[match.groups()[1]].append(issue.iid)
330 else:
331 script_issues[match.groups()[1]].append(issue.iid)
332
333 # get list of available revision hashes
334 rev_list = get_json_object_list(
336 validationpath.file_name_comparison_json,
337 )
338
339 for r in rev_list:
340 comparison_json_path = os.path.join(
342 r,
343 validationpath.file_name_comparison_json,
344 )
345 comparison_json = deliver_json(comparison_json_path)
346 for package in comparison_json["packages"]:
347 for plotfile in package.get("plotfiles"):
348 for plot in plotfile.get("plots"):
349 if plot["png_filename"] in plot_issues.keys():
350 plot["issue"] = plot_issues[plot["png_filename"]]
351 else:
352 plot["issue"] = []
353
354 with open(comparison_json_path, "w") as jsonFile:
355 json.dump(comparison_json, jsonFile, indent=4)
356
357 # get list of available revision labels
358 rev_list = get_json_object_list(
360 validationpath.file_name_results_json,
361 )
362 for r in rev_list:
363 revision_json_path = os.path.join(
365 r,
366 validationpath.file_name_results_json,
367 )
368 revision_json = deliver_json(revision_json_path)
369 for package in revision_json["packages"]:
370 for scriptfile in package.get("scriptfiles"):
371 if scriptfile["name"] in script_issues.keys():
372 scriptfile["issues"] = script_issues[scriptfile['name']]
373 else:
374 scriptfile["issues"] = []
375
376 with open(revision_json_path, "w") as jsonFile:
377 json.dump(revision_json, jsonFile, indent=4)
378
379
380def upload_file_gitlab(
381 file_path: str, project: 'gitlab.project'
382) -> Dict[str, str]:
383 """
384 Upload the passed file to the Gitlab project.
385
386 Returns:
387 uploaded gitlab project file object
388 """
389
390 uploaded_file = project.upload(
391 file_path.split("/")[-1], filepath=file_path
392 )
393
394 return uploaded_file
395
396
397def get_librarians(package: str) -> List[str]:
398 """
399 Function to get package librarian(s)' GitLab usernames. Temp solution
400 until the .librarians file directly provides Gitlab usernames.
401
402 Return:
403 list of librarians' Gitlab usernames
404 """
405
406 usernames = []
407 librarian_file = os.path.join(
409 package,
410 '.librarians'
411 )
412 try:
413 with open(librarian_file, 'r') as f:
414 librarians = f.readlines()
415 except FileNotFoundError:
416 logging.exception(
417 f"{librarian_file} couldn't be found. Corrupted package/librarian file?"
418 )
419 return usernames
420 # Temp workaround to fetch DESY -> Gitlab map
421 import importlib
422 desy_map_path = os.path.join(
423 "/home/b2soft/gitlab",
424 "account_map.py"
425 )
426 spec = importlib.util.spec_from_file_location('account_map', desy_map_path)
427 desy_map = importlib.util.module_from_spec(spec)
428 try:
429 spec.loader.exec_module(desy_map)
430 except FileNotFoundError:
431 logging.exception(
432 f"{desy_map_path} couldn't be found. Have you setup Gitlab Webhook?"
433 )
434 return usernames
435
436 for librarian in librarians:
437 usernames.append(desy_map.get_gitlab_account(librarian.rstrip()))
438
439 return usernames
440
441
442def parse_contact(
443 contact: str,
444 map_file: str,
445 package: str,
446 gitlab_object: gitlab.Gitlab
447) -> List[str]:
448 """
449 Parse string to find email id(s) and then match them with their Gitlab ids
450 using the userid map.
451
452 Returns :
453 Dictionary with list of Gitlab IDs and corresponding list of
454 Gitlab usernames.
455 """
456
457 email_regex = re.compile(r"([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*"
458 r"|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([-!#-'*+/-9="
459 r"?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\[[\t -Z^-~]"
460 r"*])"
461 )
462 email_ids = re.finditer(email_regex, contact)
463 assignees = {
464 'gitlab_ids': [],
465 'usernames': [],
466 }
467
468 try:
469 with open(map_file, 'r') as f:
470 id_map = f.readlines()
471 except FileNotFoundError:
472 logging.exception(
473 f"{map_file} couldn't be found. Did you get the location right?"
474 )
475 email_ids = []
476
477 for email in email_ids:
478 try:
479 match = next(
480 (line for line in id_map if email.group() in line), None
481 )
482 if not match:
483 logging.error(
484 f"No userid found for {email} in the map, could it be that "
485 "they are (sadly) no longer in the collaboration?"
486 )
487 continue
488 username = match.split(' ')[1].rstrip()
489 assignees['usernames'].append(username)
490 except IndexError:
491 logging.error(
492 f"Map info {match} does not match the required format for map "
493 "'email gitlab_username'."
494 )
495 continue
496
497 # Assign to librarian(s) if no contact found
498 if not assignees['usernames']:
499 assignees['usernames'] = get_librarians(package)
500 logging.info(
501 "Couldn't find contact/id so assigning issue to the"
502 " package librarians."
503 )
504
505 for user in assignees['usernames']:
506 try:
507 assignees['gitlab_ids'].append(
508 gitlab_object.users.list(username=user)[0].id
509 )
510 except IndexError:
511 logging.error(
512 f"Could not find {user} in Gitlab."
513 )
514 continue
515 logging.info(
516 "Issue will be assigned to "
517 f"{[gitlab_object.users.get(id) for id in assignees['gitlab_ids']]}."
518 )
519
520 # to-do: add comment/note if no ids matched
521
522 return assignees
523
524
525def create_gitlab_issue(
526 title: str,
527 description: str,
528 uploaded_file: Dict[str, str],
529 assignees: Dict[str, List],
530 package: str,
531 project: 'gitlab.project'
532) -> str:
533 """
534 Create a new project issue with the passed title, description and package,
535 using Gitlab API.
536
537 Returns:
538 created issue id
539 """
540
541 issue = project.issues.create({"title": title,
542 "description": description,
543 "labels": [package, 'validation_issue']})
544
545 issue_note = issue.notes.create(
546 {"body": f'View the [error plot/log file]({uploaded_file["url"]}).'}
547 )
548
549 issue.assignee_ids = assignees['gitlab_ids']
550
551 # Workaround for Gitlab not allowing multiple assignees
552 if len(assignees['gitlab_ids']) > 1:
553 related_users = [f'@{user} ' for user in assignees['usernames'][1:]]
554 issue_note.body += f"\n\nPinging {' '.join(related_users)}"
555 issue_note.save()
556
557 issue.save()
558
559 logging.info(f"Created a new Gitlab issue - {issue.iid}")
560
561 return issue.iid
562
563
564def update_gitlab_issue(
565 issue_iid: str,
566 uploaded_file: Dict[str, str],
567 project: 'gitlab.project',
568 file_path: str,
569 rev_label: str
570) -> None:
571 """
572 Update an existing project issue with the passed plotfile.
573
574 Returns:
575 None
576 """
577
578 issue = project.issues.get(issue_iid)
579 name = file_path.split("/")[-1].split(".")[0]
580 package = file_path.split("/")[-2]
581 # check if this is a plot/script based on file format
582 issue_type = 'plot'
583 if 'log' == file_path.split("/")[-1].split(".")[-1]:
584 issue_type = 'script'
585 issue.notes.create(
586 {
587 "body": f'Related observation in validation of `{package}` package, `{name}`' +
588 f'{issue_type} in `{rev_label}` build. View the [error plot/log file]({uploaded_file["url"]}).'
589 }
590 )
591
592 issue.save()
593
594 logging.info(f"Updated existing Gitlab issue {issue.iid}")
595
596
597def update_scriptfile_issues_json(
598 revision_json_path: str,
599 scritptfile_name: str,
600 scritptfile_package: str,
601 issue_id: str
602) -> None:
603 """
604 Update the scriptfile's linked issues key in the relevant revision's
605 json file.
606
607 Returns:
608 None
609 """
610
611 revision_json = deliver_json(revision_json_path)
612 for package in revision_json["packages"]:
613 if package["name"] == scritptfile_package:
614 for scriptfile in package.get("scriptfiles"):
615 if (scriptfile["name"] == scritptfile_name):
616 scriptfile["issues"].append(issue_id)
617 break
618
619 with open(revision_json_path, "w") as jsonFile:
620 json.dump(revision_json, jsonFile, indent=4)
621
622
623def update_plot_issues_json(
624 comparison_json_path: str,
625 plot_name: str,
626 plot_package: str,
627 issue_id: str
628) -> None:
629 """
630 Update the plotfile's linked issues key in the relevant comparison
631 json file.
632
633 Returns:
634 None
635 """
636
637 comparison_json = deliver_json(comparison_json_path)
638 for package in comparison_json["packages"]:
639 if package["name"] == plot_package:
640 for plotfile in package.get("plotfiles"):
641 for plot in plotfile.get("plots"):
642 if (plot["png_filename"] == plot_name):
643 plot["issue"].append(issue_id)
644 break
645
646 with open(comparison_json_path, "w") as jsonFile:
647 json.dump(comparison_json, jsonFile, indent=4)
648
649
650class ValidationRoot:
652 """
653 Root Validation class to handle non-static HTTP requests into the
654 validation server. The two main functions are to hand out compiled json
655 objects of revisions and comparisons and to start and monitor the
656 creation of comparison plots.
657
658 """
659
660 def __init__(self, working_folder, gitlab_object, gitlab_config, gitlab_map):
661 """
662 class initializer, which takes the path to the folders containing the
663 validation run results and plots (aka comparison), gitlab object and
664 config
665 """
666
667
668 self.working_folder = working_folder
670
671 self.last_restart = datetime.datetime.now()
673
675 os.environ["BELLE2_LOCAL_DIR"]
676 )
677
678
679 self.gitlab_object = gitlab_object
681
682 self.gitlab_config = gitlab_config
684
685 self.gitlab_map = gitlab_map
687
688 self.file_path = None
690 self.revision_label = None
692 self.contact = None
694 @cherrypy.expose
695 @cherrypy.tools.json_in()
696 @cherrypy.tools.json_out()
697 def create_comparison(self):
698 """
699 Triggers the start of a now comparison between the revisions supplied
700 in revision_list
701 """
702 rev_list = cherrypy.request.json["revision_list"]
703 logging.debug("Creating plots for revisions: " + str(rev_list))
704 progress_key = start_plotting_request(
705 rev_list,
707 )
708 return {"progress_key": progress_key}
709
710 @cherrypy.expose
711 def index(self):
712 """
713 forward to the static landing page if
714 the default url is used (like http://localhost:8080/)
715 """
716 raise cherrypy.HTTPRedirect("/static/validation.html")
717
718 @cherrypy.expose
719 def plots(self, *args):
720 """
721 Serve file from the html/plot directory.
722 :param args: For the request /plots/a/b/c, these will be the strings
723 "a", "b", "c"
724 """
725
726 warn_wrong_directory()
727
728 if len(args) < 3:
729 raise cherrypy.HTTPError(404)
730
731 tag_folder = os.path.relpath(
733 self.working_folder, args[:-2]
734 ),
736 )
737 path = os.path.join(tag_folder, *args[-2:])
738 return cherrypy.lib.static.serve_file(path)
739
740 @cherrypy.expose
741 @cherrypy.tools.json_in()
742 @cherrypy.tools.json_out()
743 def check_comparison_status(self):
744 """
745 Checks on the status of a comparison creation
746 """
747 progress_key = cherrypy.request.json["input"]
748 logging.debug("Checking status for plot creation: " + str(progress_key))
749 status = check_plotting_status(progress_key)
750 return status
751
752 @cherrypy.expose
753 @cherrypy.tools.json_out()
754 def revisions(self, revision_label=None):
755 """
756 Return a combined json object with all revisions and
757 mark the newest one with the field most_recent=true
758 """
759
760 # get list of available revision
761 rev_list = get_json_object_list(
763 validationpath.file_name_results_json,
764 )
765
766 # always add the reference revision
767 combined_list = []
768 reference_revision = json.loads(
769 json_objects.dumps(json_objects.Revision(label="reference"))
770 )
771
772 # load and combine
773 for r in rev_list:
774 full_path = os.path.join(
776 r,
777 validationpath.file_name_results_json,
778 )
779
780 # update label, if dir has been moved
781 lbl_folder = get_revision_label_from_json_filename(full_path)
782 j = deliver_json(full_path)
783 j["label"] = lbl_folder
784 combined_list.append(j)
785
786 # Sorting
787
788 # Order by categories (nightly, release, etc.) first, then by date
789 # A pure chronological order doesn't make sense, because we do not
790 # have a linear history ((pre)releases branch off) and for the builds
791 # the date corresponds to the build date, not to the date of the
792 # actual commit.
793 def sort_key(label: str):
794 if "-" not in label:
795 logging.warning(
796 f"Misformatted label encountered: '{label}' "
797 f"(doesn't seem to include date?)"
798 )
799 return label
800 category, datetag = label.split("-", maxsplit=1)
801 print(category, datetag)
802 # Will later reverse order to bring items in the same category
803 # in reverse chronological order, so the following list will have
804 # the items in reverse order as well:
805 order = ["release", "prerelease", "nightly"]
806 try:
807 index = order.index(category)
808 except ValueError:
809 index = 9
810 logging.warning(
811 f"Misformatted label encountered: '{label}' (doesn't seem "
812 f"to belong to any known category?)"
813 )
814 return f"{index}-{datetag}"
815
816 combined_list.sort(key=lambda rev: sort_key(rev["label"]), reverse=True)
817
818 # reference always on top
819 combined_list = [reference_revision] + combined_list
820
821 # Set the most recent one ...
822 newest_date = None
823 newest_rev = None
824 for r in combined_list:
825 rdate_str = r["creation_date"]
826 if isinstance(rdate_str, str):
827 if len(rdate_str) > 0:
828 try:
829 rdate = time.strptime(rdate_str, "%Y-%m-%d %H:%M")
830 except ValueError:
831 # some old validation results might still contain
832 # seconds and therefore cannot properly be converted
833 rdate = None
834
835 if rdate is None:
836 continue
837
838 if newest_date is None:
839 newest_date = rdate
840 newest_rev = r
841 if rdate > newest_date:
842 newest_date = rdate
843 newest_rev = r
844
845 for c in combined_list:
846 if c["most_recent"] is not None:
847 c["most_recent"] = False
848
849 # if there are no revisions at all, this might also be just None
850 if newest_rev:
851 newest_rev["most_recent"] = True
852
853 # topmost item must be dictionary for the ractive.os template to match
854 return {"revisions": combined_list}
855
856 @cherrypy.expose
857 @cherrypy.tools.json_out()
858 def comparisons(self, comparison_label=None):
859 """
860 return the json file of the comparison results of one specific
861 comparison
862 """
863
864 warn_wrong_directory()
865
866 # todo: Make this independent of our working directory!
867 path = os.path.join(
868 os.path.relpath(
870 self.working_folder, comparison_label.split(",")
871 ),
873 ),
874 "comparison.json",
875 )
876
877 # check if this comparison actually exists
878 if not os.path.isfile(path):
879 raise cherrypy.HTTPError(
880 404, f"Json Comparison file {path} does not exist"
881 )
882
883 return deliver_json(path)
884
885 @cherrypy.expose
886 @cherrypy.tools.json_out()
887 def system_info(self):
888 """
889 Returns:
890 JSON file containing git versions and time of last restart
891 """
892
893 warn_wrong_directory()
894
895 # note: for some reason %Z doesn't work like this, so we use
896 # time.tzname for the time zone.
897 return {
898 "last_restart": self.last_restart.strftime("%-d %b %H:%M ")
899 + time.tzname[1],
900 "version_restart": self.version,
902 os.environ["BELLE2_LOCAL_DIR"]
903 ),
904 }
905
906 @cherrypy.expose
907 def retrieve_file_metadata(self, filename):
908 """
909 Returns:
910 Metadata(str) of the file
911 """
912 cherrypy.response.headers['Content-Type'] = 'text/plain'
913 metadata = validationfunctions.get_file_metadata(filename)
914 return metadata
915
916 @cherrypy.expose
917 @cherrypy.tools.json_in()
918 @cherrypy.tools.json_out()
919 def create_issue(self, title, description):
920 """
921 Call the functions to create the issue and redirect
922 to the created Gitlab issue page.
923 """
924
925 # check if this is a plot/script based on file format
926 issue_type = 'plot'
927 if 'log' == self.file_path.split("/")[-1].split(".")[-1]:
928 issue_type = 'script'
929 default_section = self.gitlab_config['global']['default']
930 project_id = self.gitlab_config[default_section]['project_id']
931 # Create issue in the Gitlab project and save it
932 project = get_project_object(self.gitlab_object, project_id)
933 uploaded_file = upload_file_gitlab(self.file_path, project)
934 assignees = {
935 'gitlab_ids': [],
936 'usernames': [],
937 }
938 file_name = self.file_path.split("/")[-1].split(".log")[0]
939 file_package = self.file_path.split("/")[-2]
940 if self.gitlab_map:
941 assignees = parse_contact(
942 self.contact, self.gitlab_map, file_package, self.gitlab_object
943 )
944 description += "\n\n---\n\n:robot: Automated code, please do not delete\n\n" + \
945 f"Relevant {issue_type}: {file_name}\n\n" + \
946 f"Revision label: {self.revision_label}\n\n---"
947 issue_id = create_gitlab_issue(
948 title, description, uploaded_file, assignees, file_package, project
949 )
950 project.save()
951
952 # Update JSON with created issue id - script and plot info reside
953 # in different locations and also have different structures.
954 # todo - maybe this can be combined?
955 if issue_type == 'script':
956 revision_json_path = os.path.join(
958 self.revision_label,
959 validationpath.file_name_results_json,
960 )
961 update_scriptfile_issues_json(
962 revision_json_path, file_name, file_package, issue_id)
963 else:
964 comparison_json_path = os.path.join(
966 self.file_path.split("/")[-3],
967 "comparison.json",
968 )
969 update_plot_issues_json(
970 comparison_json_path, file_name, file_package, issue_id)
971
972 issue_url = self.gitlab_config[default_section]['project_url'] \
973 + "/-/issues/" \
974 + str(issue_id)
975 raise cherrypy.HTTPRedirect(
976 issue_url
977 )
978
979 @cherrypy.expose
980 def issue(self, file_path, rev_label, contact):
981 """
982 Return a template issue creation interface
983 for the user to add title and description.
984 """
985 self.file_path = os.path.join(
987 )
988
989 self.revision_label = rev_label
990 self.contact = contact
991
992 if not self.gitlab_object:
993 return "ERROR: Gitlab integration not set up, verify config file."
994
995 raise cherrypy.HTTPRedirect("/static/validation_issue.html")
996
997 @cherrypy.expose
998 def issue_redirect(self, iid):
999 """
1000 Redirect to the Gitlab issue page.
1001 """
1002 default_section = self.gitlab_config['global']['default']
1003 if not self.gitlab_config[default_section]['project_url']:
1004 return "ERROR: Gitlab integration not set up, verify config file."
1005
1006 issue_url = self.gitlab_config[default_section]['project_url'] \
1007 + "/-/issues/" \
1008 + str(iid)
1009 raise cherrypy.HTTPRedirect(
1010 issue_url
1011 )
1012
1013 @cherrypy.expose
1014 def update_issue(self, id, file_path, rev_label):
1015 """
1016 Update existing issue in Gitlab with current result plot
1017 and redirect to the updated Gitlab issue page.
1018 """
1019
1020 if not self.gitlab_object:
1021 return "ERROR: Gitlab integration not set up, verify config file."
1022
1023 plot_path = os.path.join(
1025 )
1026
1027 default_section = self.gitlab_config['global']['default']
1028 project_id = self.gitlab_config[default_section]['project_id']
1029 project = get_project_object(self.gitlab_object, project_id)
1030 uploaded_file = upload_file_gitlab(plot_path, project)
1031 update_gitlab_issue(
1032 id, uploaded_file, project, plot_path, rev_label
1033 )
1034 project.save()
1035
1036 issue_url = self.gitlab_config[default_section]['project_url'] \
1037 + "/-/issues/" \
1038 + str(id)
1039
1040 raise cherrypy.HTTPRedirect(
1041 issue_url
1042 )
1043
1044
1045def setup_gzip_compression(path, cherry_config):
1046 """
1047 enable GZip compression for all text-based content the
1048 web-server will deliver
1049 """
1050
1051 cherry_config[path].update(
1052 {
1053 "tools.gzip.on": True,
1054 "tools.gzip.mime_types": [
1055 "text/html",
1056 "text/plain",
1057 "text/css",
1058 "application/javascript",
1059 "application/json",
1060 ],
1061 }
1062 )
1063
1064
1065def get_argument_parser():
1066 """
1067 Prepare a parser for all the known command line arguments
1068 """
1069
1070 # Set up the command line parser
1071 parser = argparse.ArgumentParser()
1072
1073 # Define the accepted command line flags and read them in
1074 parser.add_argument(
1075 "-ip",
1076 "--ip",
1077 help="The IP address on which the"
1078 "server starts. Default is '127.0.0.1'.",
1079 type=str,
1080 default="127.0.0.1",
1081 )
1082 parser.add_argument(
1083 "-p",
1084 "--port",
1085 help="The port number on which"
1086 " the server starts. Default is '8000'.",
1087 type=str,
1088 default=8000,
1089 )
1090 parser.add_argument(
1091 "-v",
1092 "--view",
1093 help="Open validation website" " in the system's default browser.",
1094 action="store_true",
1095 )
1096 parser.add_argument(
1097 "--production",
1098 help="Run in production environment: "
1099 "no log/error output via website and no auto-reload",
1100 action="store_true",
1101 )
1102 parser.add_argument(
1103 "-u",
1104 "--usermap",
1105 help="Path of file containing <email gitlab_username> map.",
1106 type=str,
1107 default=None,
1108 )
1109
1110 return parser
1111
1112
1113def parse_cmd_line_arguments():
1114 """!
1115 Sets up a parser for command line arguments,
1116 parses them and returns the arguments.
1117 @return: An object containing the parsed command line arguments.
1118 Arguments are accessed like they are attributes of the object,
1119 i.e. [name_of_object].[desired_argument]
1120 """
1121 parser = get_argument_parser()
1122 # Return the parsed arguments!
1123 return parser.parse_args()
1124
1125
1126def run_server(
1127 ip="127.0.0.1",
1128 port=8000,
1129 parse_command_line=False,
1130 open_site=False,
1131 dry_run=False,
1132):
1133
1134 # Setup options for logging
1135 logging.basicConfig(
1136 level=logging.DEBUG,
1137 format="%(asctime)s %(levelname)-8s %(message)s",
1138 datefmt="%H:%M:%S",
1139 )
1140
1141 basepath = validationpath.get_basepath()
1142 cwd_folder = os.getcwd()
1143
1144 # Only execute the program if a basf2 release is set up!
1145 if (
1146 os.environ.get("BELLE2_RELEASE_DIR", None) is None
1147 and os.environ.get("BELLE2_LOCAL_DIR", None) is None
1148 ):
1149 sys.exit("Error: No basf2 release set up!")
1150
1151 cherry_config = dict()
1152 # just empty, will be filled below
1153 cherry_config["/"] = {}
1154 # will ensure also the json requests are gzipped
1155 setup_gzip_compression("/", cherry_config)
1156
1157 # check if static files are provided via central release
1158 static_folder_list = ["validation", "html_static"]
1159 static_folder = None
1160
1161 if basepath["central"] is not None:
1162 static_folder_central = os.path.join(
1163 basepath["central"], *static_folder_list
1164 )
1165 if os.path.isdir(static_folder_central):
1166 static_folder = static_folder_central
1167
1168 # check if there is also a collection of static files in the local release
1169 # this overwrites the usage of the central release
1170 if basepath["local"] is not None:
1171 static_folder_local = os.path.join(
1172 basepath["local"], *static_folder_list
1173 )
1174 if os.path.isdir(static_folder_local):
1175 static_folder = static_folder_local
1176
1177 if static_folder is None:
1178 sys.exit(
1179 "Either BELLE2_RELEASE_DIR or BELLE2_LOCAL_DIR has to set "
1180 "to provide static HTML content. Did you run b2setup ?"
1181 )
1182
1183 # join the paths of the various result folders
1184 results_folder = validationpath.get_results_folder(cwd_folder)
1185 comparison_folder = validationpath.get_html_plots_folder(cwd_folder)
1186
1187 logging.info(f"Serving static content from {static_folder}")
1188 logging.info(f"Serving result content and plots from {cwd_folder}")
1189
1190 # check if the results folder exists and has at least one folder
1191 if not os.path.isdir(results_folder):
1192 sys.exit(
1193 f"Result folder {results_folder} does not exist, run validate_basf2 first " +
1194 "to create validation output"
1195 )
1196
1197 results_count = sum(
1198 [
1199 os.path.isdir(os.path.join(results_folder, f))
1200 for f in os.listdir(results_folder)
1201 ]
1202 )
1203 if results_count == 0:
1204 sys.exit(
1205 f"Result folder {results_folder} contains no folders, run "
1206 f"validate_basf2 first to create validation output"
1207 )
1208
1209 # Go to the html directory
1210 if not os.path.exists("html"):
1211 os.mkdir("html")
1212 os.chdir("html")
1213
1214 if not os.path.exists("plots"):
1215 os.mkdir("plots")
1216
1217 if os.path.exists("plots/rainbow.json"):
1218 logging.info("Removing old plots and unpopular combinations")
1220 comparison_folder,
1222 )
1223
1224 # export js, css and html templates
1225 cherry_config["/static"] = {
1226 "tools.staticdir.on": True,
1227 # only serve js, css, html and png files
1228 "tools.staticdir.match": r"^.*\.(js|css|html|png|js.map)$",
1229 "tools.staticdir.dir": static_folder,
1230 }
1231 setup_gzip_compression("/static", cherry_config)
1232
1233 # export generated plots
1234 cherry_config["/plots"] = {
1235 "tools.staticdir.on": True,
1236 # only serve json and png files
1237 "tools.staticdir.match": r"^.*\.(png|json|pdf)$",
1238 "tools.staticdir.dir": comparison_folder,
1239 }
1240 setup_gzip_compression("/plots", cherry_config)
1241
1242 # export generated results and raw root files
1243 cherry_config["/results"] = {
1244 "tools.staticdir.on": True,
1245 "tools.staticdir.dir": results_folder,
1246 # only serve root, log and txt files
1247 "tools.staticdir.match": r"^.*\.(log|root|txt)$",
1248 # server the log files as plain text files, and make sure to use
1249 # utf-8 encoding. Firefox might decide different, if the files
1250 # are located on a .jp domain and use Shift_JIS
1251 "tools.staticdir.content_types": {
1252 "log": "text/plain; charset=utf-8",
1253 "root": "application/octet-stream",
1254 },
1255 }
1256
1257 setup_gzip_compression("/results", cherry_config)
1258
1259 # Define the server address and port
1260 # only if we got some specific
1261 production_env = False
1262 if parse_command_line:
1263 # Parse command line arguments
1264 cmd_arguments = parse_cmd_line_arguments()
1265
1266 ip = cmd_arguments.ip
1267 port = int(cmd_arguments.port)
1268 open_site = cmd_arguments.view
1269 production_env = cmd_arguments.production
1270 usermap_file = cmd_arguments.usermap
1271
1272 cherrypy.config.update(
1273 {
1274 "server.socket_host": ip,
1275 "server.socket_port": port,
1276 }
1277 )
1278 if production_env:
1279 cherrypy.config.update({"environment": "production"})
1280
1281 logging.info(f"Server: Starting HTTP server on {ip}:{port}")
1282
1283 if open_site:
1284 webbrowser.open("http://" + ip + ":" + str(port))
1285
1286 config_path = os.path.join(static_folder, '../config/gl.cfg')
1287
1288 if not dry_run:
1289 # gitlab toggle
1290 gitlab_object = None
1291 gitlab_config = None
1292 gitlab_map = None
1293 if not os.path.exists(config_path):
1294 logging.warning(
1295 "ERROR: Expected to find config folder with Gitlab config,"
1296 f" but {config_path} doesn't exist. "
1297 "Gitlab features will not work."
1298 )
1299 else:
1300 gitlab_config = get_gitlab_config(config_path)
1301 gitlab_object = create_gitlab_object(config_path)
1302 if gitlab_object:
1303 update_linked_issues(gitlab_object, cwd_folder)
1304 gitlab_map = usermap_file
1305 logging.info(
1306 f"{gitlab_map} will be used to assign issues."
1307 )
1308
1309 cherrypy.quickstart(
1311 working_folder=cwd_folder,
1312 gitlab_object=gitlab_object,
1313 gitlab_config=gitlab_config,
1314 gitlab_map=gitlab_map,
1315 ),
1316 "/",
1317 cherry_config,
1318 )
1319
1320
1321if __name__ == "__main__":
1322 run_server()
1323
contact
placeholder variable for contact
last_restart
Date when this object was instantiated.
def comparisons(self, comparison_label=None)
def __init__(self, working_folder, gitlab_object, gitlab_config, gitlab_map)
def issue(self, file_path, rev_label, contact)
def revisions(self, revision_label=None)
def retrieve_file_metadata(self, filename)
file_path
placeholder variable for path
working_folder
html folder that contains plots etc.
def update_issue(self, id, file_path, rev_label)
def create_issue(self, title, description)
revision_label
placeholder variable for revision label
def dumps(obj)
Optional[str] get_compact_git_hash(str repo_folder)
str get_file_metadata(str filename)
def clear_plots(str work_folder, List[str] keep_revisions)
List[str] get_popular_revision_combinations(str work_folder)
def get_html_folder(output_base_dir)
def get_html_plots_folder(output_base_dir)
def get_results_folder(output_base_dir)
def get_html_plots_tag_comparison_folder(output_base_dir, tags)