Belle II Software  release-06-02-00
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  # add contact of failed script to mail_log
143  try:
144  for contact in parse_mail_address(script.contact):
145  if contact not in mail_log:
146  mail_log[contact] = {}
147  mail_log[contact][script.name] = failed_script
148  except (KeyError, TypeError):
149  # this means no contact is given
150  continue
151 
152  return mail_log
153 
155  self, comparison, include_expert_plots=False
156  ) -> Dict[str, Dict[str, Dict[str, str]]]:
157  """!
158  Takes the entire comparison json file, finds all the plots where
159  comparison failed, finds info about failed scripts and saves them in
160  the following format:
161 
162  {
163  "email@address.test" : {
164  "title1": {
165  "package": str,
166  "description": str,
167  "rootfile": str,
168  "comparison_text": str,
169  "description": str,
170  "comparison_result": str,
171  "warnings": str
172  },
173  "title2": {...}
174  },
175  "mail@...": {...}
176  }
177 
178  The top level ordering is the email address of the contact to make
179  sure every user gets only one mail with everything in it.
180  """
181 
182  mail_log = {}
183  # search for plots where comparison resulted in an error
184  for package in comparison["packages"]:
185  for plotfile in package["plotfiles"]:
186  for plot in plotfile["plots"]:
187  if not include_expert_plots and plot["is_expert"]:
188  continue
189  skip = True
190  if plot["comparison_result"] in ["error"]:
191  skip = False
192  if set(plot["warnings"]) - {"No reference object"}:
193  skip = False
194  if skip:
195  continue
196  # save all the information that's needed for
197  # an informative email
198  error_data = {
199  "package": plotfile["package"],
200  "rootfile": plotfile["rootfile"],
201  "comparison_text": plot["comparison_text"],
202  "description": plot["description"],
203  "comparison_result": plot["comparison_result"],
204  "warnings": sorted(
205  list(
206  set(plot["warnings"]) - {"No reference object"}
207  )
208  ),
209  }
210  # every contact gets an email
211  for contact in parse_mail_address(plot["contact"]):
212  # check if this contact already gets mail
213  if contact not in mail_log:
214  # create new key for this contact
215  mail_log[contact] = {}
216  mail_log[contact][plot["title"]] = error_data
217 
218  # now get failed scripts and merge information into mail_log
219  failed_scripts = self._create_mail_log_failed_scripts_create_mail_log_failed_scripts()
220  for contact in failed_scripts:
221  # if this user is not yet represented in mail_log, create new key
222  if contact not in mail_log:
223  mail_log[contact] = failed_scripts[contact]
224  # if user already is in mail_log, add the failed scripts
225  else:
226  for script in failed_scripts[contact]:
227  mail_log[contact][script] = failed_scripts[contact][script]
228 
229  return self._flag_new_failures_flag_new_failures(mail_log, self._mail_data_old_mail_data_old)
230 
231  @staticmethod
233  mail_log: Dict[str, Dict[str, Dict[str, str]]],
234  old_mail_log: Optional[Dict[str, Dict[str, Dict[str, str]]]],
235  ) -> Dict[str, Dict[str, Dict[str, str]]]:
236  """ Add a new field 'compared_to_yesterday' which takes one of the
237  values 'unchanged' (same revision comparison result as in yesterday's
238  mail log, 'new' (new warning/failure), 'changed' (comparison result
239  changed). """
240  mail_log_flagged = copy.deepcopy(mail_log)
241  for contact in mail_log:
242  for plot in mail_log[contact]:
243  if old_mail_log is None:
244  mail_log_flagged[contact][plot][
245  "compared_to_yesterday"
246  ] = "n/a"
247  elif contact not in old_mail_log:
248  mail_log_flagged[contact][plot][
249  "compared_to_yesterday"
250  ] = "new"
251  elif plot not in old_mail_log[contact]:
252  mail_log_flagged[contact][plot][
253  "compared_to_yesterday"
254  ] = "new"
255  elif (
256  mail_log[contact][plot]["comparison_result"]
257  != old_mail_log[contact][plot]["comparison_result"]
258  or mail_log[contact][plot]["warnings"]
259  != old_mail_log[contact][plot]["warnings"]
260  ):
261  mail_log_flagged[contact][plot][
262  "compared_to_yesterday"
263  ] = "changed"
264  else:
265  mail_log_flagged[contact][plot][
266  "compared_to_yesterday"
267  ] = "unchanged"
268  return mail_log_flagged
269 
270  @staticmethod
271  def _check_if_same(plot_errors: Dict[str, Dict[str, str]]) -> bool:
272  """
273  @param plot_errors: ``_create_mail_log[contact]``.
274  @return True, if there is at least one new/changed plot status
275  """
276  for plot in plot_errors:
277  if plot_errors[plot]["compared_to_yesterday"] != "unchanged":
278  return False
279  return True
280 
281  @staticmethod
282  def _compose_message(plots, incremental=True):
283  """!
284  Takes a dict (like in _create_mail_log) and composes a mail body
285  @param plots
286  @param incremental (bool): Is this an incremental report or a full
287  ("Monday") report?
288  """
289 
290  # link to validation page
291  url = "https://b2-master.belle2.org/validation/static/validation.html"
292  # url = "http://localhost:8000/static/validation.html"
293 
294  if incremental:
295  body = (
296  "You are receiving this email, because additional"
297  " validation plots/scripts (that include you as contact "
298  "person) produced warnings/errors or "
299  "because their warning/error status "
300  "changed. \n"
301  "Below is a detailed list of all new/changed offenders:\n\n"
302  )
303  else:
304  body = (
305  "This is a full list of validation plots/scripts that"
306  " produced warnings/errors and include you as contact"
307  "person (sent out once a week).\n\n"
308  )
309 
310  body += (
311  "There were problems with the validation of the "
312  "following plots/scripts:\n\n"
313  )
314  for plot in plots:
315  compared_to_yesterday = plots[plot]["compared_to_yesterday"]
316  body_plot = ""
317  if compared_to_yesterday == "unchanged":
318  if incremental:
319  # Do not include.
320  continue
321  elif compared_to_yesterday == "new":
322  body_plot = '<b style="color: red;">[NEW]</b><br>'
323  elif compared_to_yesterday == "changed":
324  body_plot = (
325  '<b style="color: red;">'
326  "[Warnings/comparison CHANGED]</b><br>"
327  )
328  else:
329  body_plot = (
330  f'<b style="color: red;">[UNEXPECTED compared_to_yesterday '
331  f'flag: "{compared_to_yesterday}". Please alert the '
332  f"validation maintainer.]</b><br>"
333  )
334 
335  # compose descriptive error message
336  if plots[plot]["comparison_result"] == "error":
337  errormsg = "comparison unequal"
338  elif plots[plot]["comparison_result"] == "not_compared":
339  errormsg = ""
340  else:
341  errormsg = plots[plot]["comparison_result"]
342 
343  body_plot += "<b>{plot}</b><br>"
344  body_plot += "<b>Package:</b> {package}<br>"
345  body_plot += "<b>Rootfile:</b> {rootfile}.root<br>"
346  body_plot += "<b>Description:</b> {description}<br>"
347  body_plot += "<b>Comparison:</b> {comparison_text}<br>"
348  if errormsg:
349  body_plot += f"<b>Error:</b> {errormsg}<br>"
350  warnings_str = ", ".join(plots[plot]["warnings"]).strip()
351  if warnings_str:
352  body_plot += f"<b>Warnings:</b> {warnings_str}<br>"
353  # URLs are currently not working.
354  # if plots[plot]["rootfile"] != "--":
355  # body_plot += '<a href="{url}#{package}-{rootfile}">' \
356  # 'Click me for details</a>'
357  body_plot += "\n\n"
358 
359  # Fill in fields
360  body_plot = body_plot.format(
361  plot=plot,
362  package=plots[plot]["package"],
363  rootfile=plots[plot]["rootfile"],
364  description=plots[plot]["description"],
365  comparison_text=plots[plot]["comparison_text"],
366  url=url,
367  )
368 
369  body += body_plot
370 
371  body += (
372  f"You can take a look at the plots/scripts "
373  f'<a href="{url}">here</a>.'
374  )
375 
376  return body
377 
378  # todo: this logic should probably be put somewhere else
379  @staticmethod
380  def _force_full_report() -> bool:
381  """ Should a full (=non incremental) report be sent?
382  Use case e.g.: Send a full report every Monday.
383  """
384  is_monday = date.today().weekday() == 0
385  if is_monday:
386  print("Forcing full report because today is Monday.")
387  return True
388  return False
389 
390  def send_all_mails(self, incremental=None):
391  """
392  Send mails to all contacts in self.mail_data_new. If
393  self.mail_data_old is given, a mail is only sent if there are new
394  failed plots
395  @param incremental: True/False/None (=automatic). Whether to send a
396  full or incremental report.
397  """
398  if incremental is None:
399  incremental = not self._force_full_report_force_full_report()
400  if not incremental:
401  print("Sending full ('Monday') report.")
402  else:
403  print("Sending incremental report.")
404 
405  recipients = []
406  for contact in self._mail_data_new_mail_data_new:
407  # if the errors are the same as yesterday, don't send a new mail
408  if incremental and self._check_if_same_check_if_same(
409  self._mail_data_new_mail_data_new[contact]
410  ):
411  # don't send mail
412  continue
413  recipients.append(contact)
414 
415  # set the mood of the b2bot
416  if len(self._mail_data_new_mail_data_new[contact]) < 4:
417  mood = "meh"
418  elif len(self._mail_data_new_mail_data_new[contact]) < 7:
419  mood = "angry"
420  elif len(self._mail_data_new_mail_data_new[contact]) < 10:
421  mood = "livid"
422  else:
423  mood = "dead"
424 
425  body = self._compose_message_compose_message(
426  self._mail_data_new_mail_data_new[contact], incremental=incremental
427  )
428 
429  if incremental:
430  header = "Validation: New/changed warnings/errors"
431  else:
432  header = "Validation: Monday report"
433 
435  contact.split("@")[0], contact, header, body, mood=mood
436  )
437 
438  # send a happy mail to folks whose failed plots work now
439  if self._mail_data_old_mail_data_old:
440  for contact in self._mail_data_old_mail_data_old:
441  if contact not in self._mail_data_new_mail_data_new:
442  recipients.append(contact)
443  body = "Your validation plots work fine now!"
445  contact.split("@")[0],
446  contact,
447  "Validation confirmation",
448  body,
449  mood="happy",
450  )
451 
452  print(f"Sent mails to the following people: {', '.join(recipients)}")
453 
454  def write_log(self):
455  """
456  Dump mail json.
457  """
458  with open(
459  os.path.join(self._validator_validator.get_log_folder(), "mail_data.json"),
460  "w",
461  ) as f:
462  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:380
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:271
_validator
Instance of validation.Validation.
Definition: mail_log.py:71
def send_all_mails(self, incremental=None)
Definition: mail_log.py:390
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:235
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:156
def write_log(self)
Definition: mail_log.py:454
def _compose_message(plots, incremental=True)
Takes a dict (like in _create_mail_log) and composes a mail body.
Definition: mail_log.py:282
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)