Belle II Software  release-05-02-19
validationserver.py
1 # std
2 from typing import Dict, Any, List, Tuple
3 from glob import glob
4 import json
5 import functools
6 import time
7 import datetime
8 from multiprocessing import Process, Queue
9 import os.path
10 import argparse
11 import logging
12 import sys
13 import queue
14 import webbrowser
15 
16 # 3rd
17 import cherrypy
18 
19 # ours
20 import json_objects
21 from validationplots import create_plots
22 import validationfunctions
23 import validationpath
24 
25 g_plottingProcesses = {} # type: Dict[str, Tuple[Process, Queue, Dict[str, Any]]]
26 
27 
28 def get_revision_label_from_json_filename(json_filename: str) -> str:
29  """
30  Gets the label of a revision from the path to the revision.json file
31  for example results/r121/revision.json
32  will result in the label r121
33  This is useful if the results folder has been moved by the user
34  """
35  folder_part = os.path.split(json_filename)[0]
36  last_folder = os.path.basename(folder_part)
37 
38  return last_folder
39 
40 
41 def get_json_object_list(results_folder: str, json_file_name: str) -> List[str]:
42  """
43  Searches one folder's sub-folder for json files of a
44  specific name and returns a combined list of the
45  json file's content
46  """
47 
48  search_string = results_folder + "/*/" + json_file_name
49 
50  found_revs = glob(search_string)
51  found_rev_labels = []
52 
53  for r_file in found_revs:
54  # try loading json file
55  with open(r_file) as json_file:
56  data = json.load(json_file)
57 
58  # always use the folder name as label
59  found_rev_labels.append(
60  get_revision_label_from_json_filename(r_file)
61  )
62 
63  return found_rev_labels
64 
65 
66 def deliver_json(file_name: str):
67  """
68  Simply load & parse a json file and return the
69  python objects
70  """
71 
72  with open(file_name) as json_file:
73  data = json.load(json_file)
74  return data
75 
76 
77 def create_revision_key(revision_names: List[str]) -> str:
78  """
79  Create a string key out of a revision list, which is handed to tho browser
80  in form of a progress key
81  """
82  return functools.reduce(lambda x, y: x + "-" + y, revision_names, "")
83 
84 
85 def check_plotting_status(progress_key: str):
86  """
87  Check the plotting status via the supplied progress_key
88  """
89 
90  if progress_key not in g_plottingProcesses:
91  return None
92 
93  process, qu, last_status = \
94  g_plottingProcesses[progress_key]
95 
96  # read latest message
97  try:
98  # read as much entries from the queue as possible
99  while not qu.empty():
100  msg = qu.get_nowait()
101  last_status = msg
102 
103  # update the last status
104  g_plottingProcesses[progress_key] = (process, qu, last_status)
105  except queue.Empty:
106  pass
107 
108  return last_status
109 
110 
111 # todo: remove this, once we're certain that the bug was fixed!
112 def warn_wrong_directory():
113  if not os.getcwd().endswith("html"):
114  print(f"ERROR: Expected to be in HTML directory, but my current "
115  f"working directory is {os.getcwd()}; abspath: {os.getcwd()}.")
116 
117 
118 # todo: limit the number of running plotting requests and terminate hanging ones
119 def start_plotting_request(revision_names: List[str], results_folder: str) -> str:
120  """
121  Start a new comparison between the supplied revisions
122 
123  Returns:
124  revision key
125  """
126 
127  rev_key = create_revision_key(revision_names)
128 
129  # still running a plotting for this combination ?
130  if rev_key in g_plottingProcesses:
131  logging.info("Plotting request for {} still running".format(rev_key))
132  return rev_key
133 
134  # create queue to stream progress, only one directional from parent to
135  # child
136  qu = Queue()
137 
138  # start a new process for creating the plots
139  p = Process(
140  target=create_plots,
141  args=(
142  revision_names,
143  False,
144  qu,
145  # go one folder up, because this function
146  # expects the work dir, which contains
147  # the results folder
148  os.path.dirname(results_folder)
149  )
150  )
151  p.start()
152  g_plottingProcesses[rev_key] = (p, qu, None)
153 
154  logging.info("Started process for plotting request {}".format(rev_key))
155 
156  return rev_key
157 
158 
159 class ValidationRoot(object):
160 
161  """
162  Root Validation class to handle non-static HTTP requests into the
163  validation server. The two main functions are to hand out compiled json
164  objects of revisions and comparisons and to start and monitor the
165  creation of comparison plots.
166 
167  """
168 
169  def __init__(self, working_folder):
170  """
171  class initializer, which takes the path to the folders containing the
172  validation run results and plots (aka comparison)
173  """
174 
175 
176  self.working_folder = working_folder
177 
178 
179  self.last_restart = datetime.datetime.now()
180 
181 
183  os.environ["BELLE2_LOCAL_DIR"]
184  )
185 
186  @cherrypy.expose
187  @cherrypy.tools.json_in()
188  @cherrypy.tools.json_out()
189  def create_comparison(self):
190  """
191  Triggers the start of a now comparison between the revisions supplied
192  in revision_list
193  """
194  rev_list = cherrypy.request.json["revision_list"]
195  logging.debug('Creating plots for revisions: ' + str(rev_list))
196  progress_key = start_plotting_request(
197  rev_list,
199  )
200  return {"progress_key": progress_key}
201 
202  @cherrypy.expose
203  def index(self):
204  """
205  forward to the static landing page if
206  the default url is used (like http://localhost:8080/)
207  """
208  raise cherrypy.HTTPRedirect("/static/validation.html")
209 
210  @cherrypy.expose
211  def plots(self, *args):
212  """
213  Serve file from the html/plot directory.
214  :param args: For the request /plots/a/b/c, these will be the strings
215  "a", "b", "c"
216  """
217 
218  warn_wrong_directory()
219 
220  if len(args) < 3:
221  raise cherrypy.HTTPError(404)
222 
223  tag_folder = os.path.relpath(
225  self.working_folder, args[:-2]
226  ),
228  )
229  path = os.path.join(tag_folder, *args[-2:])
230  return cherrypy.lib.static.serve_file(path)
231 
232  @cherrypy.expose
233  @cherrypy.tools.json_in()
234  @cherrypy.tools.json_out()
236  """
237  Checks on the status of a comparison creation
238  """
239  progress_key = cherrypy.request.json["input"]
240  logging.debug('Checking status for plot creation: ' +
241  str(progress_key))
242  status = check_plotting_status(progress_key)
243  return status
244 
245  @cherrypy.expose
246  @cherrypy.tools.json_out()
247  def revisions(self, revision_label=None):
248  """
249  Return a combined json object with all revisions and
250  mark the newest one with the field most_recent=true
251  """
252 
253  # get list of available revision
254  rev_list = get_json_object_list(
256  validationpath.file_name_results_json
257  )
258 
259  # always add the reference revision
260  combined_list = []
261  reference_revision = json.loads(
263  json_objects.Revision(label="reference")
264  )
265  )
266 
267  # load and combine
268  for r in rev_list:
269  full_path = os.path.join(
271  r,
272  validationpath.file_name_results_json
273  )
274 
275  # update label, if dir has been moved
276  lbl_folder = get_revision_label_from_json_filename(full_path)
277  j = deliver_json(full_path)
278  j["label"] = lbl_folder
279  combined_list.append(j)
280 
281  # Sorting
282 
283  # Order by categories (nightly, build, etc.) first, then by date
284  # A pure chronological order doesn't make sense, because we do not
285  # have a linear history ((pre)releases branch off) and for the builds
286  # the date corresponds to the build date, not to the date of the
287  # actual commit.
288  def sort_key(label: str):
289  if "-" not in label:
290  logging.warning(
291  f"Misformatted label encountered: '{label}' "
292  f"(doesn't seem to include date?)"
293  )
294  return label
295  category, datetag = label.split("-", maxsplit=1)
296  print(category, datetag)
297  # Will later reverse order to bring items in the same category
298  # in reverse chronological order, so the following list will have
299  # the items in reverse order as well:
300  order = [
301  "release",
302  "prerelease",
303  "build",
304  "nightly"
305  ]
306  try:
307  index = order.index(category)
308  except ValueError:
309  index = 9
310  logging.warning(
311  f"Misformatted label encountered: '{label}' (doesn't seem "
312  f"to belong to any known category?)"
313  )
314  return "{}-{}".format(index, datetag)
315 
316  combined_list.sort(
317  key=lambda rev: sort_key(rev["label"]),
318  reverse=True
319  )
320 
321  # reference always on top
322  combined_list = [reference_revision] + combined_list
323 
324  # Set the most recent one ...
325  newest_date = None
326  newest_rev = None
327  for r in combined_list:
328  rdate_str = r["creation_date"]
329  if isinstance(rdate_str, str):
330  if len(rdate_str) > 0:
331  try:
332  rdate = time.strptime(rdate_str, "%Y-%m-%d %H:%M")
333  except ValueError:
334  # some old validation results might still contain
335  # seconds and therefore cannot properly be converted
336  rdate = None
337 
338  if rdate is None:
339  continue
340 
341  if newest_date is None:
342  newest_date = rdate
343  newest_rev = r
344  if rdate > newest_date:
345  newest_date = rdate
346  newest_rev = r
347 
348  for c in combined_list:
349  if c["most_recent"] is not None:
350  c["most_recent"] = False
351 
352  # if there are no revisions at all, this might also be just None
353  if newest_rev:
354  newest_rev["most_recent"] = True
355 
356  # topmost item must be dictionary for the ractive.os template to match
357  return {"revisions": combined_list}
358 
359  @cherrypy.expose
360  @cherrypy.tools.json_out()
361  def comparisons(self, comparison_label=None):
362  """
363  return the json file of the comparison results of one specific
364  comparison
365  """
366 
367  warn_wrong_directory()
368 
369  # todo: Make this independent of our working directory!
370  path = os.path.join(
371  os.path.relpath(
373  self.working_folder, comparison_label.split(",")
374  ),
376  ),
377  "comparison.json"
378  )
379 
380  # check if this comparison actually exists
381  if not os.path.isfile(path):
382  raise cherrypy.HTTPError(
383  404,
384  f"Json Comparison file {path} does not exist"
385  )
386 
387  return deliver_json(path)
388 
389  @cherrypy.expose
390  @cherrypy.tools.json_out()
391  def system_info(self):
392  """
393  Returns:
394  JSON file containing git versions and time of last restart
395  """
396 
397  warn_wrong_directory()
398 
399  # note: for some reason %Z doesn't work like this, so we use
400  # time.tzname for the time zone.
401  return {
402  "last_restart":
403  self.last_restart.strftime("%-d %b %H:%M ") + time.tzname[1],
404  "version_restart": self.version,
405  "version_current":
407  os.environ["BELLE2_LOCAL_DIR"]
408  )
409  }
410 
411 
412 def setup_gzip_compression(path, cherry_config):
413  """
414  enable GZip compression for all text-based content the
415  web-server will deliver
416  """
417 
418  cherry_config[path].update(
419  {
420  'tools.gzip.on': True,
421  'tools.gzip.mime_types': [
422  'text/html',
423  'text/plain',
424  'text/css',
425  'application/javascript',
426  'application/json'
427  ]
428  }
429  )
430 
431 
432 def get_argument_parser():
433  """Prepare a parser for all the known command line arguments"""
434 
435  # Set up the command line parser
436  parser = argparse.ArgumentParser()
437 
438  # Define the accepted command line flags and read them in
439  parser.add_argument("-ip", "--ip", help="The IP address on which the"
440  "server starts. Default is '127.0.0.1'.",
441  type=str, default='127.0.0.1')
442  parser.add_argument("-p", "--port", help="The port number on which"
443  " the server starts. Default is '8000'.",
444  type=str, default=8000)
445  parser.add_argument("-v", "--view", help="Open validation website"
446  " in the system's default browser.",
447  action='store_true')
448  parser.add_argument("--production", help="Run in production environment: "
449  "no log/error output via website and no auto-reload",
450  action="store_true")
451 
452  return parser
453 
454 
455 def parse_cmd_line_arguments():
456  """!
457  Sets up a parser for command line arguments,
458  parses them and returns the arguments.
459  @return: An object containing the parsed command line arguments.
460  Arguments are accessed like they are attributes of the object,
461  i.e. [name_of_object].[desired_argument]
462  """
463  parser = get_argument_parser()
464  # Return the parsed arguments!
465  return parser.parse_args()
466 
467 
468 def run_server(ip='127.0.0.1', port=8000, parse_command_line=False,
469  open_site=False, dry_run=False):
470 
471  # Setup options for logging
472  logging.basicConfig(level=logging.DEBUG,
473  format='%(asctime)s %(levelname)-8s %(message)s',
474  datefmt='%H:%M:%S')
475 
476  basepath = validationpath.get_basepath()
477  cwd_folder = os.getcwd()
478 
479  # Only execute the program if a basf2 release is set up!
480  if os.environ.get('BELLE2_RELEASE_DIR', None) is None and os.environ.get('BELLE2_LOCAL_DIR', None) is None:
481  sys.exit('Error: No basf2 release set up!')
482 
483  cherry_config = dict()
484  # just empty, will be filled below
485  cherry_config["/"] = {}
486  # will ensure also the json requests are gzipped
487  setup_gzip_compression("/", cherry_config)
488 
489  # check if static files are provided via central release
490  static_folder_list = ["validation", "html_static"]
491  static_folder = None
492 
493  if basepath["central"] is not None:
494  static_folder_central = os.path.join(
495  basepath["central"], *static_folder_list)
496  if os.path.isdir(static_folder_central):
497  static_folder = static_folder_central
498 
499  # check if there is also a collection of static files in the local release
500  # this overwrites the usage of the central release
501  if basepath["local"] is not None:
502  static_folder_local = os.path.join(
503  basepath["local"], *static_folder_list)
504  if os.path.isdir(static_folder_local):
505  static_folder = static_folder_local
506 
507  if static_folder is None:
508  sys.exit("Either BELLE2_RELEASE_DIR or BELLE2_LOCAL_DIR has to bet "
509  "to provide static HTML content. Did you run b2setup ?")
510 
511  # join the paths of the various result folders
512  results_folder = validationpath.get_results_folder(cwd_folder)
513  comparison_folder = validationpath.get_html_plots_folder(cwd_folder)
514 
515  logging.info(f"Serving static content from {static_folder}")
516  logging.info(f"Serving result content and plots from {cwd_folder}")
517 
518  # check if the results folder exists and has at least one folder
519  if not os.path.isdir(results_folder):
520  sys.exit("Result folder {} does not exist, run validate_basf2 first "
521  "to create validation output".format(results_folder))
522 
523  results_count = sum([
524  os.path.isdir(os.path.join(results_folder, f))
525  for f in os.listdir(results_folder)
526  ])
527  if results_count == 0:
528  sys.exit(
529  f"Result folder {results_folder} contains no folders, run "
530  f"validate_basf2 first to create validation output")
531 
532  # Go to the html directory
533  if not os.path.exists('html'):
534  os.mkdir('html')
535  os.chdir('html')
536 
537  if not os.path.exists('plots'):
538  os.mkdir('plots')
539 
540  # export js, css and html templates
541  cherry_config["/static"] = {
542  'tools.staticdir.on': True,
543  # only serve js, css, html and png files
544  'tools.staticdir.match': "^.*\.(js|css|html|png|js.map)$",
545  'tools.staticdir.dir': static_folder
546  }
547  setup_gzip_compression("/static", cherry_config)
548 
549  # export generated plots
550  cherry_config["/plots"] = {
551  'tools.staticdir.on': True,
552  # only serve json and png files
553  'tools.staticdir.match': "^.*\.(png|json|pdf)$",
554  'tools.staticdir.dir': comparison_folder
555  }
556  setup_gzip_compression("/plots", cherry_config)
557 
558  # export generated results and raw root files
559  cherry_config["/results"] = {
560  'tools.staticdir.on': True,
561  'tools.staticdir.dir': results_folder,
562  # only serve root files
563  'tools.staticdir.match': "^.*\.(log|root)$",
564  # server the log files as plain text files, and make sure to use
565  # utf-8 encoding. Firefox might decide different, if the files
566  # are located on a .jp domain and use Shift_JIS
567  'tools.staticdir.content_types': {
568  'log': 'text/plain; charset=utf-8',
569  'root': 'application/octet-stream'
570  }
571  }
572 
573  setup_gzip_compression("/results", cherry_config)
574 
575  # Define the server address and port
576  # only if we got some specific
577  production_env = False
578  if parse_command_line:
579  # Parse command line arguments
580  cmd_arguments = parse_cmd_line_arguments()
581 
582  ip = cmd_arguments.ip
583  port = int(cmd_arguments.port)
584  open_site = cmd_arguments.view
585  production_env = cmd_arguments.production
586 
587  cherrypy.config.update({'server.socket_host': ip,
588  'server.socket_port': port,
589  })
590  if production_env:
591  cherrypy.config.update({'environment': 'production'})
592 
593  logging.info(f"Server: Starting HTTP server on {ip}:{port}")
594 
595  if open_site:
596  webbrowser.open("http://" + ip + ":" + str(port))
597 
598  if not dry_run:
599  cherrypy.quickstart(
601  working_folder=cwd_folder
602  ),
603  '/',
604  cherry_config
605  )
606 
607 
608 if __name__ == '__main__':
609  run_server()
json_objects.dumps
def dumps(obj)
Definition: json_objects.py:563
validationserver.ValidationRoot.create_comparison
def create_comparison(self)
Definition: validationserver.py:189
validationserver.ValidationRoot
Definition: validationserver.py:159
validationserver.ValidationRoot.last_restart
last_restart
Date when this object was instantiated.
Definition: validationserver.py:179
validationfunctions.get_compact_git_hash
Optional[str] get_compact_git_hash(str repo_folder)
Definition: validationfunctions.py:44
validationserver.ValidationRoot.check_comparison_status
def check_comparison_status(self)
Definition: validationserver.py:235
validationpath.get_html_folder
def get_html_folder(output_base_dir)
Return the absolute path to the results folder.
Definition: validationpath.py:77
validationpath.get_results_folder
def get_results_folder(output_base_dir)
Return the absolute path to the results folder.
Definition: validationpath.py:70
validationserver.ValidationRoot.index
def index(self)
Definition: validationserver.py:203
validationserver.ValidationRoot.revisions
def revisions(self, revision_label=None)
Definition: validationserver.py:247
json_objects.Revision
Definition: json_objects.py:28
validationpath.get_html_plots_tag_comparison_folder
def get_html_plots_tag_comparison_folder(output_base_dir, tags)
Return the absolute path to the results folder.
Definition: validationpath.py:91
validationserver.ValidationRoot.comparisons
def comparisons(self, comparison_label=None)
Definition: validationserver.py:361
validationpath.get_html_plots_folder
def get_html_plots_folder(output_base_dir)
Return the absolute path to generated plots in the html folder.
Definition: validationpath.py:84
validationserver.ValidationRoot.working_folder
working_folder
html folder that contains plots etc.
Definition: validationserver.py:176
validationserver.ValidationRoot.__init__
def __init__(self, working_folder)
Definition: validationserver.py:169
validationserver.ValidationRoot.version
version
Git version.
Definition: validationserver.py:182
validationpath.get_basepath
def get_basepath()
Definition: validationpath.py:21
validationserver.ValidationRoot.system_info
def system_info(self)
Definition: validationserver.py:391
validationserver.ValidationRoot.plots
def plots(self, *args)
Definition: validationserver.py:211