Skip to content

Commit

Permalink
feat: added email notification
Browse files Browse the repository at this point in the history
  • Loading branch information
DavyVan committed May 19, 2021
1 parent 09fdc6c commit 3007e82
Show file tree
Hide file tree
Showing 15 changed files with 308 additions and 147 deletions.
21 changes: 21 additions & 0 deletions MatrixTest/EmailService/EmailProvider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import abc
from typing import Dict, List


class EmailProvider:
"""
This is the base class of all email sending services. User can extend this abstract class to connect other email services.
"""
@abc.abstractmethod
def send(self, matrix: Dict[str, List[str]], to: str, attachment_path: str = None) -> bool:
"""
Send the email.
:param matrix: The argument matrix will be included in the email for reference.
:param to: The recipient.
:param attachment_path: The Excel file.
:return: if the sending is successfully executed. Returning ``True`` does NOT mean the email has already been delivered,
the meaning of return value may vary across difference email service platforms.
"""
pass
58 changes: 58 additions & 0 deletions MatrixTest/EmailService/MailjetProvider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from typing import Dict, List
import os
import base64

import mailjet_rest as mailjet

from . import EmailProvider


class MailjetProvider(EmailProvider):
"""
This is the adaptor class for `Mailjet <https://mailjet.com>`_.
"""
def __init__(self, *, api_key: str, api_secret: str):
self.client = mailjet.Client(auth=(api_key, api_secret), version='v3.1') # type: mailjet.Client

def send(self, matrix: Dict[str, List[str]], to: str, attachment_path: str = None) -> bool:
# check attached file size
if attachment_path is not None:
if os.stat(attachment_path).st_size / 1024 / 1024 >= 15: # >= 15MB
print("Attached file is too large.")
return False

# generate string from matrix
body = "Your MatrixTest job has completed. The argument matrix is shown below:\n"
for k, v in matrix.items():
body += "%s : %s\n" % (k, v)

# send
data = {
"From": {
"Email": "matrixtest@funqtion.xyz",
"Name": "MatrixTest"
},
"To": [
{
"Email": to
}
],
"Subject": "MatrixTest job has completed",
"TextPart": body
}
if attachment_path is not None:
attachment_fd = open(attachment_path, 'rb')
data['Attachments'] = [{
"ContentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Filename": os.path.basename(attachment_path),
"Base64Content": base64.b64encode(attachment_fd.read()).decode()
}]
result = self.client.send.create(data={'Messages': [data]})

if result.status_code != 200:
print(result.status_code)
print(result.json())
return False
else:
return True
2 changes: 2 additions & 0 deletions MatrixTest/EmailService/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .EmailProvider import EmailProvider
from .MailjetProvider import MailjetProvider
115 changes: 112 additions & 3 deletions MatrixTest/MatrixTestRunner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
import textwrap
import time
import signal
import re

from .Utils import *
from .Printers import *
from .EmailService import *


def sigint_handler_wrapper(obj: "MatrixTestRunner"):
Expand Down Expand Up @@ -80,7 +82,8 @@ def __argument_matrix_checker(self) -> bool:
return fields_matrix == fields_cmd

def __init__(self, cmd: str, matrix: Dict[str, List[str]], parser: Callable[[str], Any] = None, enable_echo: bool = False,
logfile: str = None, timing: bool = False):
logfile: str = None, timing: bool = False, enable_email_notification: bool = False,
email_provider: EmailProvider = None, email_recipient: str = ""):
"""
Instantiate ``MatrixTestRunner``, checking the user input and initializing options.
Expand All @@ -102,6 +105,14 @@ def __init__(self, cmd: str, matrix: Dict[str, List[str]], parser: Callable[[str
If the result returned from parser function includes a key of "time" (this is how the execution time is recorded,
this feature will be disabled and a warning will be displayed.
:param enable_email_notification: If ``True``, an email will be sent at the end of :func:`run()`, and the email
service provider and the recipient must be given either by the following two arguments or by calling
:func:`enable_email_notification()`.
Please note: if you also set ``send_by_email`` when calling :func:`to_excel()`, you will receive two emails
with same content but one of them has attachment.
:param email_provider: Instance of email service provider. Refer to :mod:`EmailService`.
:param email_recipient: The email address of recipient. Only one recipient is allowed.
"""

colorama.init() # enable ANSI support on Windows for colored output
Expand Down Expand Up @@ -155,6 +166,12 @@ def __init__(self, cmd: str, matrix: Dict[str, List[str]], parser: Callable[[str
self.__option_echo = enable_echo
self.__terminal_width, _ = shutil.get_terminal_size() # this is used by self.__option_echo

# email
# argumetn check will be performed in __send_email()
self.__enable_email_notification = enable_email_notification # type: bool
self.__email_provider = email_provider # type: Optional[EmailProvider]
self.__email_recipient = email_recipient # type: Optional[str]

# Ctrl-C handler
signal.signal(signal.SIGINT, sigint_handler_wrapper(self))

Expand Down Expand Up @@ -277,10 +294,16 @@ def run(self, repeat: int = 1) -> None:
break

print_plain("All done.", self.__log_fd)

# close log file
if self.__log_enabled:
self.__log_fd.close()
self.__log_fd = None

# send email
if self.__enable_email_notification:
self.__send_email()

def get_last_result(self) -> pd.DataFrame:
"""
Expand Down Expand Up @@ -350,14 +373,19 @@ def average(self, column: Union[str, List[str]] = None) -> None:
self.__last_result["avg_"+item] = self.__last_result[columns_in_result].mean(axis=1, numeric_only=True)
self.__last_aggregated_columns.append("avg_"+item)

def to_excel(self, path: str, include_agg: bool = True, include_raw: bool = True) -> None:
def to_excel(self, path: str, include_agg: bool = True, include_raw: bool = True, send_by_email: bool = False) -> None:
"""
Export to Excel spreadsheet.
:param path: str. The file path to output.
:param include_agg: bool. Indicates whether include the aggregated columns or not, such as those generated by average().
:param include_raw: bool. Indicates whether include the original results. If it is set to False but have no
aggregated results, this argument will be ignored.
aggregated results, this argument will be ignored.
:param send_by_email: If ``True``, the generated Excel file will be sent via email. The email provider and recipient
must be registered at initialization or by calling :func:`enable_email_notification()`.
Please note: if you also set ``enable_email_notification`` at initialization or by calling :func:`enable_email_notification()`,
you will receive two emails with same content but one of them has attachment.
:return: None
"""

Expand Down Expand Up @@ -389,6 +417,34 @@ def to_excel(self, path: str, include_agg: bool = True, include_raw: bool = True
self.__last_result.to_excel(path, sheet_name="MatrixTest", columns=columns_list, index=False)
print_ok("Done.")

if send_by_email:
self.__send_email(path)

def __send_email(self, attachment_path: str = None) -> None:
"""
Wrapper function to send email notification.
:return:
"""
print_plain("Sending email notification...", self.__log_fd, end="")

# check provider and recipient is given
if self.__email_provider is None or self.__email_recipient is None:
print_warning("Email service provider or recipient is not registered at initialization or by calling enable_email_notification().", self.__log_fd)
return

# check email address format
if not re.fullmatch("[^@]+@[^@]+\.[^@]+", self.__email_recipient):
print_warning("Wrong email address format.", self.__log_fd)
return

# attachment file check will be performed inside the provider because platform-specified requirements.
if self.__email_provider.send(self.__matrix, self.__email_recipient, attachment_path):
print_ok("Done.", self.__log_fd)
print_plain("Please remember to check your junk/spam box.")
else:
print_warning("Failed.", self.__log_fd)

def enable_echo(self) -> None:
"""
Enable echo feature, output the ``cmd``'s ``stdout`` in real time.
Expand All @@ -407,6 +463,15 @@ def disable_echo(self) -> None:
"""
self.__option_echo = False

def enable_log(self, logfile: str) -> None:
"""
Enable log to file feature. Alias of :func:`change_logfile()`.
:param logfile: path to the log file
:return: None
"""
self.change_logfile(logfile)

def disable_log(self) -> None:
"""
Disable log to file feature.
Expand Down Expand Up @@ -450,3 +515,47 @@ def disable_timing(self) -> None:
:return: None
"""
self.__timing_enabled = False

def register_email_service(self, provider: EmailProvider, recipient: str) -> None:
"""
Register email service provider and recipient. This function will NOT enable email notification. Please call
:func:`enable_email_notification()` to explicitly enable it.
:param provider: The instance of an email provider class. Refer to :mod:`EmailService`.
:param recipient: The email address of recipient. Only one recipient is allowed.
:return: None
"""
self.__email_provider = provider
self.__email_recipient = recipient

def deregister_email_service(self) -> None:
"""
Deregister email service provider and recipient, and disable email notification.
:return: None
"""
self.__email_provider = None
self.__email_recipient = None
self.__enable_email_notification = False

def enable_email_notification(self) -> None:
"""
Enable email notification. An email will be sent at the end of :func:`run()`, and the email
service provider and the recipient must be given either by the following two arguments or by calling
:func:`enable_email_notification()`, otherwise this function has no effect.
Please note: if you also set ``send_by_email`` when calling :func:`to_excel()`, you will receive two emails
with same content but one of them has attachment.
:return: None
"""
self.__enable_email_notification = True

def disable_email_notification(self) -> None:
"""
Disable email notification but keep the email service provider and recipient registered, which can be used to send
the Excel file by email.
:return: None
"""
self.__enable_email_notification = False
1 change: 1 addition & 0 deletions MatrixTest/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .MatrixTestRunner import MatrixTestRunner
from .EmailService import *
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ __*This tutorial only show the very basic usage, for full functionalities, pleas

# How to use

---


`MatrixTest` is a pure Python module so that you need to install and import it into your Python test script.

In the following How-tos, a toy script will be used as the executable.
Expand Down Expand Up @@ -44,6 +47,9 @@ arg3 # argv[3]

## Install

---


```shell
pip install MatrixTest
```
Expand All @@ -55,6 +61,8 @@ import MatrixTest

## Configure `MatrixTestRunner`

---

`MatrixTestRunner` is the main component of `MatrixTest` package.
You need to pass all the required information via its constructor:

Expand Down Expand Up @@ -107,6 +115,8 @@ Finally, just pass all three parameters into the `MatrixTestRunner` constructor

## Run

---

To start testing, call the `run()` function with a integer indicating how many times you would like to execute repeatly:

```python
Expand All @@ -116,6 +126,8 @@ To start testing, call the `run()` function with a integer indicating how many t

## Aggregate (statistics result)

---

After getting the raw data, you may calculate the aggregated results from it. Take arithmetic mean as the example here:

```python
Expand All @@ -130,6 +142,8 @@ For now, we support the following aggregation operators:

## Access the results

---

We use `pandas.DataFrame` to store all the results for the current run.
Both raw data and aggregated data are stored in a single DataFrame.

Expand Down Expand Up @@ -162,6 +176,48 @@ Generally, we recommend you to output your data to an Excel spreadsheet for furt

The first parameter is the output file path. Also, you can choose whether include raw/aggregated data in the Excel or not via the last two parameters.

## Email Notification Service

---

From version `1.3.0`, you have the option to send an email to a designated email address when the experiments finished (i.e. at the end of `run()`)
or when the Excel file is generated (i.e. at the end of `to_excel()`) then you will find the Excel file in the attachment.

### How to enable email notification

First, you need to instantiate a `EmailProvider`:
```python
email_provider = MatrixTest.EmailService.MailjetProvider(api_key='xxxx',
api_secret='xxxx')
```

For now we only support the [Mailjet](https://mailjet.com) as the email service vendor.

__Please note:__ There is a key pair in the `example.py` that you can use for free. But please __DO NOT__ send more than
200 emails per day. That is the limit of Mailjet free account. We encourage you to create your own account and then replace
the keys if you expect to receive a lot of emails.

Then, register the provider to `MatrixTestRunner` and enable the feature:
```python
mtr.register_email_service(email_provider, "example@example.com")
mtr.enable_email_notification()
```
Only one recipient is allowed.

You can also do this at initialization, refer to the [doc](https://matrixtest.readthedocs.io/en/latest/MatrixTestRunner.html#MatrixTest.MatrixTestRunner.MatrixTestRunner.__init__).

__Please note:__ By enabling this, you will only receive a notification which includes the argument matrix for your reference.
Keep reading if you want to receive the Excel file.

To receive a copy of the generate Excel file, just set the `send_by_email` argument to `True`. However, you still need to
register the provider and recipient.
```python
mtr.to_excel("./example_output.xlsx", send_by_email=True)
```

__Please note:__ You will receive two emails if you enable both of above. Usually, if you want to receive the Excel file,
just enable it once when you call `to_excel()`.

# Contributing

Any of your comments, issues, PRs are welcome and appreciated.
Expand Down
2 changes: 1 addition & 1 deletion cmd_example_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

for item in sys.argv:
print(item, flush=True)
# time.sleep(.2)
time.sleep(.1)
Loading

0 comments on commit 3007e82

Please sign in to comment.