Belle II Software  release-08-01-10
mail_log.py
1 #!/usr/bin/env python3
2 
3 
10 
11 # std
12 import copy
13 from datetime import date
14 import re
15 import os
16 import json
17 import sys
18 from typing import Dict, Union, List, Optional
19 
20 # ours
21 import validationpath
22 from validationfunctions import available_revisions
23 
24 # martin's mail utils
25 import mail_utils
26 from validationscript import Script
27 
28 
29 def parse_mail_address(obj: Union[str, List[str]]) -> List[str]:
30  """!
31  Take a string or list and return list of email addresses that appear in it
32  """
33  if isinstance(obj, str):
34  return re.findall(r"[\w.-]+@[\w.-]+", obj)
35  elif isinstance(obj, list):
36  return [
37  re.search(r"[\w.-]+@[\w.-]+", c).group()
38  for c in obj
39  if re.search(r"[\w.-]+@[\w.-]+", c) is not None
40  ]
41  else:
42  raise TypeError("must be string or list of strings")
43 
44 
45 class Mails:
46 
47  """!
48  Provides functionality to send mails in case of failed scripts / validation
49  plots.
50  The mail data is built upon instantiation, the `send_mails` method
51  sends the actual mails.
52  """
53 
54  def __init__(self, validation, include_expert_plots=False):
55  """!
56  Initializes an instance of the Mail class from an instance of the
57  Validation class. Assumes that a comparison json file exists,
58  reads it and parses it to extract information about failed plots.
59  This information, together with information about failed scripts,
60  gets stored in self.mail_data_new. If there is mail_data.json inside
61  the log folder, its contents get stored in self.mail_data_old for
62  later comparison.
63 
64  @param validation: validation.Validation instance
65  @param include_expert_plots: Should expert plots be included?
66  """
67 
68  # Cannot import Validation to type hint because this would give us a
69  # circular import.
70 
71  self._validator_validator = validation
72 
73  # read contents from comparison.json
74  work_folder = self._validator_validator.work_folder
75  revisions = ["reference"] + available_revisions(work_folder)
77  work_folder, revisions
78  )
79  with open(comparison_json_file) as f:
80  comparison_json = json.load(f)
81 
82  # yesterday's mail data
83  old_mail_data_path = os.path.join(
84  self._validator_validator.get_log_folder(), "mail_data.json"
85  )
86 
88  self._mail_data_old_mail_data_old: Optional[dict] = None
89  try:
90  with open(old_mail_data_path) as f:
91  self._mail_data_old_mail_data_old = json.load(f)
92  except FileNotFoundError:
93  print(
94  f"Could not find old mail_data.json at {old_mail_data_path}.",
95  file=sys.stderr,
96  )
97 
98 
100  self._mail_data_new_mail_data_new = self._create_mail_log_create_mail_log(
101  comparison_json, include_expert_plots=include_expert_plots
102  )
103 
104  def _create_mail_log_failed_scripts(self) -> Dict[str, Dict[str, str]]:
105  """!
106  Looks up all scripts that failed and collects information about them.
107  See :meth:`_create_mail_log` for the structure of the resulting
108  dictionary.
109  """
110 
111  # get failed scripts
112  with open(
113  os.path.join(
114  self._validator_validator.get_log_folder(), "list_of_failed_scripts.log"
115  )
116  ) as f:
117  failed_scripts = f.read().splitlines()
118 
119  # collect information about failed scripts
120  mail_log = {}
121  for failed_script in failed_scripts:
122 
123  # get_script_by_name works with _ only ...
124  failed_script = Script.sanitize_file_name(failed_script)
125  script = self._validator_validator.get_script_by_name(failed_script)
126  if script is None:
127  continue
128 
129  script.load_header()
130 
131  failed_script = {}
132  failed_script["warnings"] = []
133  # give failed_script the same format as error_data in method
134  # create_mail_log
135  failed_script["package"] = script.package
136  failed_script["rootfile"] = ", ".join(script.input_files)
137  failed_script["comparison_text"] = " -- "
138  failed_script["description"] = script.description
139  # this is called comparison_result but it is handled as error
140  # type when composing mail
141  failed_script["comparison_result"] = "script failed to execute"
142  # feat. adding links to plot/log files
143  failed_script["file_url"] = os.path.join(
144  'results',
145  self._validator_validator.tag,
146  script.package,
147  script.name_not_sanitized
148  ) + ".log"
149  # add contact of failed script to mail_log
150  try:
151  for contact in parse_mail_address(script.contact):
152  if contact not in mail_log:
153  mail_log[contact] = {}
154  mail_log[contact][script.name] = failed_script
155  except (KeyError, TypeError):
156  # this means no contact is given
157  continue
158 
159  return mail_log
160 
162  self, comparison, include_expert_plots=False
163  ) -> Dict[str, Dict[str, Dict[str, str]]]:
164  """!
165  Takes the entire comparison json file, finds all the plots where
166  comparison failed, finds info about failed scripts and saves them in
167  the following format:
168 
169  {
170  "email@address.test" : {
171  "title1": {
172  "package": str,
173  "description": str,
174  "rootfile": str,
175  "comparison_text": str,
176  "description": str,
177  "comparison_result": str,
178  "warnings": str,
179  "file_url": str
180  },
181  "title2": {...}
182  },
183  "mail@...": {...}
184  }
185 
186  The top level ordering is the email address of the contact to make
187  sure every user gets only one mail with everything in it.
188  """
189 
190  mail_log = {}
191  # search for plots where comparison resulted in an error
192  for package in comparison["packages"]:
193  for plotfile in package["plotfiles"]:
194  for plot in plotfile["plots"]:
195  if not include_expert_plots and plot["is_expert"]:
196  continue
197  skip = True
198  if plot["comparison_result"] in ["error"]:
199  skip = False
200  if set(plot["warnings"]) - {"No reference object"}:
201  skip = False
202  if skip:
203  continue
204  # save all the information that's needed for
205  # an informative email
206  error_data = {
207  "package": plotfile["package"],
208  "rootfile": plotfile["rootfile"],
209  "comparison_text": plot["comparison_text"],
210  "description": plot["description"],
211  "comparison_result": plot["comparison_result"],
212  "warnings": sorted(
213  list(
214  set(plot["warnings"]) - {"No reference object"}
215  )
216  ),
217  "file_url": os.path.join(
218  plot['plot_path'],
219  plot['png_filename']
220  ),
221  }
222  # every contact gets an email
223  for contact in parse_mail_address(plot["contact"]):
224  # check if this contact already gets mail
225  if contact not in mail_log:
226  # create new key for this contact
227  mail_log[contact] = {}
228  mail_log[contact][plot["title"]] = error_data
229 
230  # now get failed scripts and merge information into mail_log
231  failed_scripts = self._create_mail_log_failed_scripts_create_mail_log_failed_scripts()
232  for contact in failed_scripts:
233  # if this user is not yet represented in mail_log, create new key
234  if contact not in mail_log:
235  mail_log[contact] = failed_scripts[contact]
236  # if user already is in mail_log, add the failed scripts
237  else:
238  for script in failed_scripts[contact]:
239  mail_log[contact][script] = failed_scripts[contact][script]
240 
241  return self._flag_new_failures_flag_new_failures(mail_log, self._mail_data_old_mail_data_old)
242 
243  @staticmethod
245  mail_log: Dict[str, Dict[str, Dict[str, str]]],
246  old_mail_log: Optional[Dict[str, Dict[str, Dict[str, str]]]],
247  ) -> Dict[str, Dict[str, Dict[str, str]]]:
248  """ Add a new field 'compared_to_yesterday' which takes one of the
249  values 'unchanged' (same revision comparison result as in yesterday's
250  mail log, 'new' (new warning/failure), 'changed' (comparison result
251  changed). """
252  mail_log_flagged = copy.deepcopy(mail_log)
253  for contact in mail_log:
254  for plot in mail_log[contact]:
255  if old_mail_log is None:
256  mail_log_flagged[contact][plot][
257  "compared_to_yesterday"
258  ] = "n/a"
259  elif contact not in old_mail_log:
260  mail_log_flagged[contact][plot][
261  "compared_to_yesterday"
262  ] = "new"
263  elif plot not in old_mail_log[contact]:
264  mail_log_flagged[contact][plot][
265  "compared_to_yesterday"
266  ] = "new"
267  elif (
268  mail_log[contact][plot]["comparison_result"]
269  != old_mail_log[contact][plot]["comparison_result"]
270  or mail_log[contact][plot]["warnings"]
271  != old_mail_log[contact][plot]["warnings"]
272  ):
273  mail_log_flagged[contact][plot][
274  "compared_to_yesterday"
275  ] = "changed"
276  else:
277  mail_log_flagged[contact][plot][
278  "compared_to_yesterday"
279  ] = "unchanged"
280  return mail_log_flagged
281 
282  @staticmethod
283  def _check_if_same(plot_errors: Dict[str, Dict[str, str]]) -> bool:
284  """
285  @param plot_errors: ``_create_mail_log[contact]``.
286  @return True, if there is at least one new/changed plot status
287  """
288  for plot in plot_errors:
289  if plot_errors[plot]["compared_to_yesterday"] != "unchanged":
290  return False
291  return True
292 
293  @staticmethod
294  def _compose_message(plots, incremental=True):
295  """!
296  Takes a dict (like in _create_mail_log) and composes a mail body
297  @param plots
298  @param incremental (bool): Is this an incremental report or a full
299  ("Monday") report?
300  """
301 
302  # link to validation page
303  url = "https://b2-master.belle2.org/validation/static/validation.html"
304  # url = "http://localhost:8000/static/validation.html"
305 
306  if incremental:
307  body = (
308  "You are receiving this email, because additional"
309  " validation plots/scripts (that include you as contact "
310  "person) produced warnings/errors or "
311  "because their warning/error status "
312  "changed. \n"
313  "Below is a detailed list of all new/changed offenders:\n\n"
314  )
315  else:
316  body = (
317  "This is a full list of validation plots/scripts that"
318  " produced warnings/errors and include you as contact"
319  "person (sent out once a week).\n\n"
320  )
321 
322  body += (
323  "There were problems with the validation of the "
324  "following plots/scripts:\n\n"
325  )
326  for plot in plots:
327  compared_to_yesterday = plots[plot]["compared_to_yesterday"]
328  body_plot = ""
329  if compared_to_yesterday == "unchanged":
330  if incremental:
331  # Do not include.
332  continue
333  elif compared_to_yesterday == "new":
334  body_plot = '<b style="color: red;">[NEW]</b><br>'
335  elif compared_to_yesterday == "changed":
336  body_plot = (
337  '<b style="color: red;">'
338  "[Warnings/comparison CHANGED]</b><br>"
339  )
340  else:
341  body_plot = (
342  f'<b style="color: red;">[UNEXPECTED compared_to_yesterday '
343  f'flag: "{compared_to_yesterday}". Please alert the '
344  f"validation maintainer.]</b><br>"
345  )
346 
347  # compose descriptive error message
348  if plots[plot]["comparison_result"] == "error":
349  errormsg = "comparison unequal"
350  elif plots[plot]["comparison_result"] == "not_compared":
351  errormsg = ""
352  else:
353  errormsg = plots[plot]["comparison_result"]
354 
355  body_plot += "<b>{plot}</b><br>"
356  body_plot += "<b>Package:</b> {package}<br>"
357  body_plot += "<b>Rootfile:</b> {rootfile}.root<br>"
358  body_plot += "<b>Description:</b> {description}<br>"
359  body_plot += "<b>Comparison:</b> {comparison_text}<br>"
360  if errormsg:
361  body_plot += f"<b>Error:</b> {errormsg}<br>"
362  warnings_str = ", ".join(plots[plot]["warnings"]).strip()
363  if warnings_str:
364  body_plot += f"<b>Warnings:</b> {warnings_str}<br>"
365  body_plot += "<b>Error plot/log file:</b> <a href='{file_url}'>Click me</a><br>"
366  # URLs are currently not working.
367  # if plots[plot]["rootfile"] != "--":
368  # body_plot += '<a href="{url}#{package}-{rootfile}">' \
369  # 'Click me for details</a>'
370  body_plot += "\n\n"
371 
372  # Fill in fields
373  body_plot = body_plot.format(
374  plot=plot,
375  package=plots[plot]["package"],
376  rootfile=plots[plot]["rootfile"],
377  description=plots[plot]["description"],
378  comparison_text=plots[plot]["comparison_text"],
379  file_url=url.split('static')[0]+plots[plot]["file_url"],
380  url=url,
381  )
382 
383  body += body_plot
384 
385  body += (
386  f"You can take a look at the plots/scripts "
387  f'<a href="{url}">here</a>.'
388  )
389 
390  return body
391 
392  # todo: this logic should probably be put somewhere else
393  @staticmethod
394  def _force_full_report() -> bool:
395  """ Should a full (=non incremental) report be sent?
396  Use case e.g.: Send a full report every Monday.
397  """
398  is_monday = date.today().weekday() == 0
399  if is_monday:
400  print("Forcing full report because today is Monday.")
401  return True
402  return False
403 
404  def send_all_mails(self, incremental=None):
405  """
406  Send mails to all contacts in self.mail_data_new. If
407  self.mail_data_old is given, a mail is only sent if there are new
408  failed plots
409  @param incremental: True/False/None (=automatic). Whether to send a
410  full or incremental report.
411  """
412  if incremental is None:
413  incremental = not self._force_full_report_force_full_report()
414  if not incremental:
415  print("Sending full ('Monday') report.")
416  else:
417  print("Sending incremental report.")
418 
419  recipients = []
420  for contact in self._mail_data_new_mail_data_new:
421  # if the errors are the same as yesterday, don't send a new mail
422  if incremental and self._check_if_same_check_if_same(
423  self._mail_data_new_mail_data_new[contact]
424  ):
425  # don't send mail
426  continue
427  recipients.append(contact)
428 
429  # set the mood of the b2bot
430  if len(self._mail_data_new_mail_data_new[contact]) < 4:
431  mood = "meh"
432  elif len(self._mail_data_new_mail_data_new[contact]) < 7:
433  mood = "angry"
434  elif len(self._mail_data_new_mail_data_new[contact]) < 10:
435  mood = "livid"
436  else:
437  mood = "dead"
438 
439  body = self._compose_message_compose_message(
440  self._mail_data_new_mail_data_new[contact], incremental=incremental
441  )
442 
443  if incremental:
444  header = "Validation: New/changed warnings/errors"
445  else:
446  header = "Validation: Monday report"
447 
449  contact.split("@")[0], contact, header, body, mood=mood
450  )
451 
452  # send a happy mail to folks whose failed plots work now
453  if self._mail_data_old_mail_data_old:
454  for contact in self._mail_data_old_mail_data_old:
455  if contact not in self._mail_data_new_mail_data_new:
456  recipients.append(contact)
457  body = "Your validation plots work fine now!"
459  contact.split("@")[0],
460  contact,
461  "Validation confirmation",
462  body,
463  mood="happy",
464  )
465 
466  recipient_string = "\n".join([f"* {r}" for r in recipients])
467  print(f"Sent mails to the following people: \n{recipient_string}\n")
468 
469  def write_log(self):
470  """
471  Dump mail json.
472  """
473  with open(
474  os.path.join(self._validator_validator.get_log_folder(), "mail_data.json"),
475  "w",
476  ) as f:
477  json.dump(self._mail_data_new_mail_data_new, f, sort_keys=True, indent=4)
Provides functionality to send mails in case of failed scripts / validation plots.
Definition: mail_log.py:45
bool _force_full_report()
Definition: mail_log.py:394
Dict[str, Dict[str, str]] _create_mail_log_failed_scripts(self)
Looks up all scripts that failed and collects information about them.
Definition: mail_log.py:104
_mail_data_old
Yesterday's mail data (generated from comparison_json).
Definition: mail_log.py:91
bool _check_if_same(Dict[str, Dict[str, str]] plot_errors)
Definition: mail_log.py:283
_validator
Instance of validation.Validation.
Definition: mail_log.py:71
def send_all_mails(self, incremental=None)
Definition: mail_log.py:404
Dict[str, Dict[str, Dict[str, str]]] _flag_new_failures(Dict[str, Dict[str, Dict[str, str]]] mail_log, Optional[Dict[str, Dict[str, Dict[str, str]]]] old_mail_log)
Definition: mail_log.py:247
def __init__(self, validation, include_expert_plots=False)
Initializes an instance of the Mail class from an instance of the Validation class.
Definition: mail_log.py:54
_mail_data_new
Current mail data.
Definition: mail_log.py:100
Dict[str, Dict[str, Dict[str, str]]] _create_mail_log(self, comparison, include_expert_plots=False)
Takes the entire comparison json file, finds all the plots where comparison failed,...
Definition: mail_log.py:163
def write_log(self)
Definition: mail_log.py:469
def _compose_message(plots, incremental=True)
Takes a dict (like in _create_mail_log) and composes a mail body.
Definition: mail_log.py:294
def send_mail(name, recipient, subject, text, link=None, link_title=None, mood="normal")
Definition: mail_utils.py:78
def get_html_plots_tag_comparison_json(output_base_dir, tags)