text: __author__ = 'calvin'
  
  import configparser
  import datetime
  import glob
  import json
  import logging
  import os
  import re
  import shutil
  import smtplib
  import sys
  import time
  from email import encoders
  from email.mime.base import MIMEBase
  from email.mime.multipart import MIMEMultipart
  from email.mime.text import MIMEText
  from threading import Thread
  
  import jinja2
  
  from api import upload_report, upload_many_reports, HQ_DEFAULT_TIMEOUT, SMTP_DEFAULT_TIMEOUT
  from process import CrashReportingProcess
  from tools import analyze_traceback, repr as safe_repr
  
  
  class CrashReporter(object):
      """
      Create a context manager that emails or uploads a report to a webserver (HQ) with the traceback on a crash.
      It can be setup to do both, or just one of the upload methods.
  
      If a crash report fails to upload, the report is saved locally to the `report_dir` directory. The next time the
      CrashReporter starts up, it will attempt to upload all offline reports every `check_interval` seconds. After a
      successful upload the offline reports are deleted. A maximum of `offline_report_limit` reports are saved at any
      time. Reports are named crashreport01, crashreport02, crashreport03 and so on. The most recent report is always
      crashreport01.
  
      Report Customizing Attributes:
  
      application_name: Application name as a string to be included in the report
      application_version: Application version as a string to be included in the report
      user_identifier: User identifier as a string to add to the report
      offline_report_limit: Maximum number of offline reports to save.
      recursion_depth_limit: Maximum number of tracebacks to record in the case of RunetimeError: maximum recursion depth
                             exceeded
      max_string_length: Maximum string length for values returned in variable inspection. This prevents reports which
                         contain array data from becoming too large.
      inspection_level: The number of traceback objects (from most recent) to inspect for source code, local variables etc
  
      :param report_dir: Directory to save offline reports.
      :param watcher: Enable a thread that periodically checks for any stored offline reports and attempts to send them.
      :param check_interval: How often the watcher will attempt to send offline reports.
      :param logger: Optional logger to use.
      :param config: Path to configuration file that defines the arguments to setup_smtp and setup_hq. The file has the
                     format of a ConfigParser file with sections [SMTP] and [HQ]
  
      """
      _report_name = "crash_report_%d"
      html_template = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'email_report.html')
      active = False
      application_name = None
      application_version = None
      user_identifier = None
      offline_report_limit = 10
      recursion_depth_limit = 10
      send_at_most = 3            # max number of offline reports to send in batch
      max_string_length = 1000
      obj_ref_regex = re.compile("[A-z]+[0-9]*\.(?:[A-z]+[0-9]*\.?)+(?!\')")
  
      def __init__(self, report_dir=None, config='', logger=None, activate=True,
                   watcher=True, check_interval=5*60):
          self.logger = logger if logger else logging.getLogger('CrashReporter')
          # Setup the directory used to store offline crash reports
          self.report_dir = report_dir
          self.check_interval = check_interval
          self.watcher_enabled = watcher
          self._watcher = None
          self._watcher_running = False
          self.etype = None
          self.evalue = None
          self.tb = None
          self._recursion_error = False
          self.analyzed_traceback = None
          self.payload = None
          self._excepthook = None
          self.inspection_level = 1
          self._smtp = None
          self._hq = None
          # Load the configuration from a file if specified
          if os.path.isfile(config):
              self.load_configuration(config)
          if activate:
              self.enable()
  
      def setup_smtp(self, host, port, user, passwd, recipients, **kwargs):
          """
          Set up the crash reporter to send reports via email using SMTP
  
          :param host: SMTP host
          :param port: SMTP port
          :param user: sender email address
          :param passwd: sender email password
          :param recipients: list or comma separated string of recipients
          """
          self._smtp = kwargs
          self._smtp.update({'host': host, 'port': port, 'user': user, 'passwd': passwd, 'recipients': recipients})
          try:
              self._smtp['timeout'] = int(kwargs.get('timeout', SMTP_DEFAULT_TIMEOUT))
          except Exception as e:
              logging.error(e)
              self._smtp['timeout'] = None
          self._smtp['from'] = kwargs.get('from', user)
  
      def setup_hq(self, server, **kwargs):
          self._hq = kwargs
          try:
              self._hq['timeout'] = int(kwargs.get('timeout', HQ_DEFAULT_TIMEOUT))
          except Exception as e:
              logging.error(e)
              self._hq['timeout'] = None
          self._hq.update({'server': server})
  
      def enable(self):
          """
          Enable the crash reporter. CrashReporter is defaulted to be enabled on creation.
          """
          if not CrashReporter.active:
              CrashReporter.active = True
              # Store this function so we can set it back if the CrashReporter is deactivated
              self._excepthook = sys.excepthook
              sys.excepthook = self.exception_handler
              self.logger.info('CrashReporter: Enabled')
              if self.report_dir:
                  if os.path.exists(self.report_dir):
                      if self.get_offline_reports():
                          # First attempt to send the reports, if that fails then start the watcher
                          self.submit_offline_reports()
                          remaining_reports = len(self.get_offline_reports())
                          if remaining_reports and self.watcher_enabled:
                              self.start_watcher()
                  else:
                      os.makedirs(self.report_dir)
  
      def disable(self):
          """
          Disable the crash reporter. No reports will be sent or saved.
          """
          if CrashReporter.active:
              CrashReporter.active = False
              # Restore the original excepthook
              sys.excepthook = self._excepthook
              self.stop_watcher()
              self.logger.info('CrashReporter: Disabled')
  
      def start_watcher(self):
          """
          Start the watcher that periodically checks for offline reports and attempts to upload them.
          """
          if self._watcher and self._watcher.is_alive:
              self._watcher_running = True
          else:
              self.logger.info('CrashReporter: Starting watcher.')
              self._watcher = Thread(target=self._watcher_thread, name='offline_reporter')
              self._watcher.setDaemon(True)
              self._watcher_running = True
              self._watcher.start()
  
      def stop_watcher(self):
          """
          Stop the watcher thread that tries to send offline reports.
          """
          if self._watcher:
              self._watcher_running = False
              self.logger.info('CrashReporter: Stopping watcher.')
  
      def interprocess_exception_handler(self, err_name, err_msg, analyzed_tb):
          payload = self.generate_payload(err_name, err_msg, analyzed_tb)
          self.handle_payload(payload)
  
      def _analyze_traceback(self, traceback):
          # To prevent recording a large amount of potentially redundant tracebacks, limit the trace back for the case of
          # infinite recursion errors.
          limit = CrashReporter.recursion_depth_limit if self._recursion_error else None
          analyzed_tb = analyze_traceback(traceback, limit=limit)
          self.custom_inspection(analyzed_tb)
          # Perform serialization check on the possibly user-altered traceback
          overriden = self.__class__.custom_inspection.im_func is not CrashReporter.custom_inspection.im_func
          if overriden:
              for tb in analyzed_tb:
                  for key, value in tb['Custom Inspection'].items():
                      try:
                          json.dumps(value)
                      except TypeError:
                          tb['Custom Inspection'][key] = {k: safe_repr(v) for k, v in value.items()}
          return analyzed_tb
  
      def custom_inspection(self, analyzed_traceback):
          """
          Define this function so that users can override it and add their own custom information to
          the payload in the 'Custom Inspection' key.
          """
          return analyzed_traceback
  
      def exception_handler(self, etype, evalue, tb):
          """
          Exception hook. Catches crashes / un-caught exceptions and passes them to handle_payload()
  
          :param etype: Exception type
          :param evalue: Exception value
          :param tb: Traceback
          :return:
          """
          self.etype = etype
          self.evalue = evalue
          self.tb = tb
          self._recursion_error = "maximum recursion depth exceeded" in str(self.evalue)
  
          if etype:
              self.logger.info('CrashReporter: Crashes detected!')
              self.analyzed_traceback = self._analyze_traceback(tb)
              self.handle_payload(self.generate_payload(etype.__name__, '%s' % evalue, self.analyzed_traceback))
          else:
              self.logger.info('CrashReporter: No crashes detected.')
  
          self.forward_exception(etype, evalue, tb)
  
      def forward_exception(self, etype, evalue, tb):
          """
          Forward the exception onto the backup copy that was made of the sys.__excepthook__
  
          :param etype: Exceoption type
          :param evalue: Exception value
          :param tb: Traceback
          :return:
          """
          self._excepthook(etype, evalue, tb)
  
      def handle_payload(self, payload):
          """
          Given a crash report (JSON represented payload), attempts to upload the crash reports. Calls the default
          exception handler (sys.__except_hook__) upon completion.
          :param payload: JSON structure containing crash report along with metadata
          :return:
          """
          self.payload = payload
          if CrashReporter.active:
              # Attempt to upload the report
              hq_success = smtp_success = False
              if self._hq is not None:
                  hq_success = self.hq_submit(self.payload)
                  if hq_success:
                      self.payload['HQ Submission'] = 'Sent'
              if self._smtp is not None:
                  # Send the report via email
                  smtp_success = self.smtp_submit(self.subject(), self.body(self.payload), self.attachments())
                  if smtp_success:
                      self.payload['SMTP Submission'] = 'Sent'
  
          if not CrashReporter.active or (self._smtp and not smtp_success) or (self._hq and not hq_success):
              # Only store the offline report if any of the upload methods fail, or if the Crash Reporter was disabled
              report_path = self.store_report(self.payload)
              self.logger.info('Offline Report stored %s' % report_path)
  
      def generate_payload(self, err_name, err_msg, analyzed_tb):
          dt = datetime.datetime.now()
          payload = {'Error Type': err_name,
                     'Error Message': err_msg + self._recursion_error * " (Not all tracebacks are shown)",
                     'Application Name': self.application_name,
                     'Application Version': self.application_version,
                     'User': self.user_identifier,
                     'Date': dt.strftime('%d %B %Y'),
                     'Time': dt.strftime('%I:%M %p'),
                     'Traceback': analyzed_tb,
                     'HQ Submission': 'Not sent' if self._hq else 'Disabled',
                     'SMTP Submission': 'Not sent' if self._smtp else 'Disabled'
                     }
          return payload
  
      def load_configuration(self, config):
          cfg = configparser.ConfigParser()
  
          with open(config, 'r') as _f:
              cfg.readfp(_f)
              if cfg.has_section('General'):
                  general = dict(cfg.items('General'))
                  self.application_name = general.get('application_name', CrashReporter.application_name)
                  self.application_version = general.get('application_version', CrashReporter.application_version)
                  self.user_identifier = general.get('user_identifier', CrashReporter.user_identifier)
                  self.offline_report_limit = general.get('offline_report_limit', CrashReporter.offline_report_limit)
                  self.max_string_length = general.get('max_string_length', CrashReporter.max_string_length)
              if cfg.has_section('SMTP'):
                  self.setup_smtp(**dict(cfg.items('SMTP')))
                  if 'port' in self._smtp:
                      self._smtp['port'] = int(self._smtp['port'])
                  if 'recipients' in self._smtp:
                      self._smtp['recipients'] = self._smtp['recipients'].split(',')
  
              if cfg.has_section('HQ'):
                  self.setup_hq(**dict(cfg.items('HQ')))
  
      def subject(self):
          """
          Return a string to be used as the email subject line.
          """
          if self.application_name and self.application_version:
              return 'Crash Report - {name} (v{version})'.format(name=self.application_name,
                                                                 version=self.application_version)
          else:
              return 'Crash Report'
  
      def body(self, payload):
          return self.render_report(payload, inspection_level=self.inspection_level)
  
      def render_report(self, payload, inspection_level=1):
          with open(self.html_template, 'r') as _f:
              template = jinja2.Template(_f.read())
  
          return template.render(info=payload,
                                 inspection_level=inspection_level)
  
      def attachments(self):
          """
          Generate and return a list of attachments to send with the report.
          :return: List of strings containing the paths to the files.
          """
          return []
  
      def delete_offline_reports(self):
          """
          Delete all stored offline reports
          :return: List of reports that still require submission
          """
          reports = self.get_offline_reports()
          remaining_reports = reports[:]
          for report in reports:
              with open(report, 'r') as _f:
                  try:
                      js = json.load(_f)
                  except ValueError as e:
                      logging.error("%s. Deleting crash report.")
                      os.remove(report)
                      continue
                  if js['SMTP Submission'] in ('Sent', 'Disabled') and js['HQ Submission'] in ('Sent', 'Disabled'):
                      # Only delete the reports which have been sent or who's upload method is disabled.
                      remaining_reports.remove(report)
                      try:
                          os.remove(report)
                      except OSError as e:
                          logging.error(e)
  
          self.logger.info('CrashReporter: Deleting offline reports. %d reports remaining.' % len(remaining_reports))
          return remaining_reports
  
      def submit_offline_reports(self):
          """
          Submit offline reports using the enabled methods (SMTP and/or HQ)
          Returns a tuple of (N sent reports, N remaining reports)
          """
          smtp_enabled = bool(self._smtp)
          hq_enabled = bool(self._hq)
          offline_reports = self.get_offline_reports()
          logging.info('Submitting %d offline crash reports' % len(offline_reports))
          offline_reports = offline_reports[:self.send_at_most]
  
          if smtp_enabled:
              try:
                  smtp_success = self._smtp_send_offline_reports(*offline_reports)
              except Exception as e:
                  logging.error(e)
                  smtp_success = [False] * len(offline_reports)
          else:
              smtp_success = [True] * len(offline_reports)
  
          if hq_enabled:
              try:
                  hq_success = self._hq_send_offline_reports(*offline_reports)
              except Exception as e:
                  logging.error(e)
                  hq_success = [False] * len(offline_reports)
          else:
              hq_success = [True] * len(offline_reports)
  
          remaining_reports = self.delete_offline_reports()
          success = [s1 and s2 for (s1, s2) in zip(smtp_success, hq_success)]
          logging.info('%d crash reports successfully submitted' % success.count(True))
          logging.info('%d crash reports remain to be submitted' % len(remaining_reports))
          return all(success)
  
      def store_report(self, payload):
          """
          Save the crash report to a file. Keeping the last `offline_report_limit` files in a cyclical FIFO buffer.
          The newest crash report always named is 01
          """
          offline_reports = self.get_offline_reports()
          if offline_reports:
              # Increment the name of all existing reports 1 --> 2, 2 --> 3 etc.
              for ii, report in enumerate(reversed(offline_reports)):
                  rpath, ext = os.path.splitext(report)
                  n = int(re.findall('(\d+)', rpath)[-1])
                  new_name = os.path.join(self.report_dir, self._report_name % (n + 1)) + ext
                  shutil.copy2(report, new_name)
              os.remove(report)
              # Delete the oldest report
              if len(offline_reports) >= self.offline_report_limit:
                  oldest = glob.glob(os.path.join(self.report_dir, self._report_name % (self.offline_report_limit+1) + '*'))[0]
                  os.remove(oldest)
          new_report_path = os.path.join(self.report_dir, self._report_name % 1 + '.json')
          # Write a new report
          with open(new_report_path, 'w') as _f:
              json.dump(payload, _f)
  
          return new_report_path
  
      def hq_submit(self, payload):
          payload['HQ Parameters'] = self._hq if self._hq is not None else {}
          r = upload_report(self._hq['server'], payload, timeout=self._hq['timeout'])
          if r is False:
              return False
          else:
              return r.status_code == 200
  
      def smtp_submit(self, subject, body, attachments=None):
          smtp = self._smtp
          msg = MIMEMultipart()
          if isinstance(smtp['recipients'], list) or isinstance(smtp['recipients'], tuple):
              msg['To'] = ', '.join(smtp['recipients'])
          else:
              msg['To'] = smtp['recipients']
          msg['From'] = smtp['from']
          msg['Subject'] = subject
  
          # Add the body of the message
          msg.attach(MIMEText(body, 'html'))
  
          # Add any attachments
          if attachments:
              for attachment in attachments:
                  part = MIMEBase('application', 'octet-stream')
                  part.set_payload(open(attachments, 'rb').read())
                  encoders.encode_base64(part)
                  part.add_header('Content-Disposition',
                                  'attachment; filename="%s"' % os.path.basename(attachment))
                  msg.attach(part)
  
          try:
              ms = smtplib.SMTP(smtp['host'], smtp['port'], timeout=smtp['timeout'])
              ms.ehlo()
              ms.starttls()
              ms.ehlo()
              ms.login(smtp['user'], smtp['passwd'])
              ms.sendmail(smtp['from'], smtp['recipients'], msg.as_string())
              ms.close()
          except Exception as e:
              self.logger.error('CrashReporter: %s' % e)
              return False
  
          return True
  
      def get_offline_reports(self):
          return sorted(glob.glob(os.path.join(self.report_dir, self._report_name.replace("%d", "*"))))
  
      def poll(self):
          for remote, local in CrashReportingProcess.cr_pipes:
              if remote.poll():
                  pkg = remote.recv()
                  self.logger.debug('Interprocess payload found.')
                  self.handle_payload(self.generate_payload(*pkg))
                  return True
          return False
  
      def _watcher_thread(self):
          """
          Periodically attempt to upload the crash reports. If any upload method is successful, delete the saved reports.
          """
          while 1:
              time.sleep(self.check_interval)
              if not self._watcher_running:
                  break
              self.logger.info('CrashReporter: Attempting to send offline reports.')
              self.submit_offline_reports()
              remaining_reports = len(self.get_offline_reports())
              if remaining_reports == 0:
                  break
          self._watcher = None
          self.logger.info('CrashReporter: Watcher stopped.')
  
      def _smtp_send_offline_reports(self, *offline_reports):
          success = []
          if offline_reports:
              # Add the body of the message
              for report in offline_reports:
                  with open(report, 'r') as js:
                      payload = json.load(js)
                  if payload['SMTP Submission'] == 'Not sent':
                      success.append(self.smtp_submit(self.subject(), self.body(payload)))
                      if success[-1]:
                          # Set the flag in the payload signifying that the SMTP submission was successful
                          payload['SMTP Submission'] = 'Sent'
                          with open(report, 'w') as js:
                              json.dump(payload, js)
              self.logger.info('CrashReporter: %d Offline reports sent.' % sum(success))
          return success
  
      def _hq_send_offline_reports(self, *offline_reports):
          payloads = {}
          if offline_reports:
  
              for report in offline_reports:
                  with open(report, 'r') as _f:
                      payload = json.load(_f)
                      if payload['HQ Submission'] == 'Not sent':
                          payload['HQ Parameters'] = self._hq if self._hq is not None else {}
                          payloads[report] = payload
  
              if payloads:
                  r = upload_many_reports(self._hq['server'], payloads.values(), timeout=self._hq['timeout'])
                  if r is False or r.status_code != 200:
                      return [False] * len(payloads)
  
              # Set the flag in the payload signifying that the HQ submission was successful
              for report, payload in payloads.items():
                  payload['HQ Submission'] = 'Sent'
                  with open(report, 'w') as js:
                      json.dump(payload, js)
  
              return [True] * len(payloads)
          else:
              return [False] * len(payloads)
  