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