From b44b4ecc5a8919c502cd22524e8a02b0fe8cfbbe Mon Sep 17 00:00:00 2001 From: Rahul Verma Date: Thu, 24 Feb 2022 12:32:11 +0530 Subject: [PATCH] Docs for Email Automation --- CHANGELIST.txt | 2 + .../test/pkg/emailauto/check_email_read.py | 4 +- arjuna/tpi/parser/xml.py | 33 +- docs/source/emailauto/imap.rst | 386 +++++++++++++++++- docs/source/index.rst | 22 + 5 files changed, 433 insertions(+), 14 deletions(-) diff --git a/CHANGELIST.txt b/CHANGELIST.txt index 5a1fabb0..e7703ab6 100644 --- a/CHANGELIST.txt +++ b/CHANGELIST.txt @@ -21,6 +21,8 @@ Future Themes (In-Progress or Planned): 1.2.18 ------ +- Added support for text and attr dict based search in XML/HTML NodeLocator. +- Added documentation for Email Reading Automation 1.2.17 ------ diff --git a/arjuna-samples/arjex/test/pkg/emailauto/check_email_read.py b/arjuna-samples/arjex/test/pkg/emailauto/check_email_read.py index 528f564a..abb4f812 100644 --- a/arjuna-samples/arjex/test/pkg/emailauto/check_email_read.py +++ b/arjuna-samples/arjex/test/pkg/emailauto/check_email_read.py @@ -37,12 +37,10 @@ def check_email_links(request): ms = EmailServer.imap() mb = ms.get_mailbox() - mb._force_state(0) # Write code to Trigger the event that leads to sending email(s) to this mailbox. # After the event lemail = mb.latest(subject="Docker") print(lemail.find_link("confirm-email")) - ms.quit() - + ms.quit() \ No newline at end of file diff --git a/arjuna/tpi/parser/xml.py b/arjuna/tpi/parser/xml.py index 5db88607..96ff40e1 100644 --- a/arjuna/tpi/parser/xml.py +++ b/arjuna/tpi/parser/xml.py @@ -42,7 +42,10 @@ class NodeLocator: Keyword Arguments: tags: (Optional) Descendant tags for the node. Can be a string of single or multiple tags or a list/tuple of tags. - **attrs: Arbitrary number of key value pairs representing attribute name and value. + text: Partial text content. + attrs: Arbitrary attributes as a dictionary. Use this when the attr names are not valid Python names. + **attr_kwargs: Arbitrary number of key value pairs representing attribute name and value. The values here will override those in attr_dict if there is an overlap of name(s). + Raises: Exception: If neither tag nor an attribute is provided. @@ -53,12 +56,18 @@ class NodeLocator: Supports nested node finding. ''' - def __init__(self, *, tags: 'strOrSequence'=None, **attrs): + def __init__(self, *, tags: 'strOrSequence'=None, text=None, attrs={}, **attr_kwargs): - if tags is None and not attrs: + if tags is None and text is None and not attrs and not attr_kwargs: raise Exception("You must provided tags and/or attributes for finding nodes.") - + attr_conditions = [] + + if text: + attr_conditions.append("contains(text(), '{}')".format(text)) + + attrs.update(attr_kwargs) + if attrs: for attr, value in attrs.items(): if value is None: @@ -124,19 +133,21 @@ def get_text(self, normalize: bool=False) -> str: Text of this node. Keyword Arguments: - normalize: If True, empty lines are removed and individual lines are trimmed. + normalize: If True, all extra space is trimmed to a single space. ''' texts = self.texts if normalize: - return "".join([l for l in texts if l !="\n"]).strip() + text = "".join([l for l in texts if l !="\n"]).strip() + text = " ".join(text.split()) + return text else: return "".join(texts).strip() @property def normalized_text(self) -> str: ''' - Text of this node with empty lines removed and individual lines trimmed. + Text of this node with all extra space trimmed to a single space. ''' return self.get_text(normalize=True) @@ -446,13 +457,15 @@ def from_lxml_element(cls, element, clone=False) -> XmlNode: return XmlNode(element).clone() @classmethod - def node_locator(cls, *, tags: 'strOrSequence'=None, **attrs): + def node_locator(cls, *, tags: 'strOrSequence'=None, text=None, attrs={}, **attr_kwargs): ''' Create a locator for finding an XML Node in an **XmlNode**. Keyword Arguments: tags: (Optional) Descendant tags for the node. Can be a string of single or multiple tags or a list/tuple of tags. - **attrs: Arbitrary number of key value pairs representing attribute name and value. + text: Partial text content. + attrs: Arbitrary attributes as a dictionary. Use this when the attr names are not valid Python names. + **attr_kwargs: Arbitrary number of key value pairs representing attribute name and value. The values here will override those in attr_dict if there is an overlap of name(s). Raises: Exception: If neither tag nor an attribute is provided. @@ -462,4 +475,4 @@ def node_locator(cls, *, tags: 'strOrSequence'=None, **attrs): Supports nested node finding. ''' - return NodeLocator(tags=tags, **attrs) \ No newline at end of file + return NodeLocator(tags=tags, text=text, attrs=attrs, **attr_kwargs) \ No newline at end of file diff --git a/docs/source/emailauto/imap.rst b/docs/source/emailauto/imap.rst index f747a5b3..a4984f4e 100644 --- a/docs/source/emailauto/imap.rst +++ b/docs/source/emailauto/imap.rst @@ -3,4 +3,388 @@ **Automating Email Reading** ============================ -TO be documented. \ No newline at end of file +Arjuna provides you with library components for reading emails. + +IMAP is used as the underlying protocol. + +Following are the steps for a basic flow: + * Create an instance of IMAP server. + * Get an instance of a mailbox (for example, Inbox) + * Read email(s) using the mailbox API. + * Quit the email server. + +Go through the following sections for these steps and specific use cases. + +**Connecting to Email Server** +------------------------------ + +You can easily connect to an email server for the purpose of reading emails by providing basic connection details. + +.. code-block:: python + + server = EmailServer.imap( + host="host name or ip address", + port=345 # Provide correct port for SSL/non-SSL mode for your target server, + user="some user account", + password="password", + use_ssl=True # Do you want to use IMAPS (SSL Mode) or IMAP (non-SSL mode) + ) + + # Once the activities are completed, quit the server + server.quit() + +**Default Configuration Settings** in Arjuna for Email Server +------------------------------------------------------------- + +Arjuna has the following default settings for the following parameters: + * **host**: localhost + * **port**: 143 for non-SSL Mode and 993 for SSL-Mode. + * **use_ssl**: True + +You can make use of one or all of these defaults in your code: + +.. code-block:: python + + server = EmailServer.imap( + user="some user account", + password="password" + ) + + # Once the activities are completed, quit the server + server.quit() + + +**Project-Level Default Configuration** for Email Server +-------------------------------------------------------- + +You can override Arjuna's default settings as well as configure user account details in your test project. + +As project level defaults are overridable in a reference configuration or CLI options, you can try these options as well for more dynamic requirements. + +Following are the Arjuna options to be used in **project.yaml** file: + +.. code-block:: yaml + + arjuna_options: + emailauto.imap.host: some_host_or_ip_address + emailauto.imap.port: port_as_integer + emailauto.imap.usessl: TrueOrFalse + emailauto.user: some_user_email + emailauto.password: password + +You can connect to the server using all these settings with a simple call: + +.. code-block:: python + + server = EmailServer.imap() + + # Once the activities are completed, quit the server + server.quit() + + +**Accessing a Mailbox** from Server +----------------------------------- + +To get access to mailbox, use the get_mailbox call. By default, Inbox is returned. + +.. code-block:: python + + mailbox = server.get_mailbox() # For Inbox + mailbox = server.get_mailbox("mailbox_name") # For any other mailbox + +**Reading Latest Emails** +------------------------- + +Using the mailbox object, you can read emails using the **emails** method. + +.. code-block:: python + + emails = mailbox.emails() + +By default latest 5 emails are returned. + +You can change the count using the **max** parameters: + +.. code-block:: python + + emails = mailbox.emails(max=20, latest=False) + +You can also use **latest** call for more readable code: + +.. code-block:: python + + emails = mailbox.latest() + emails = mailbox.latest(max=20) + +**Reading Oldest Emails** +------------------------- + +At times, you might want to read the oldest emails. + +You can do this by setting **latest** parameter to False. + +.. code-block:: python + + emails = mailbox.emails(latest=False) # Oldest 5 emails + emails = mailbox.emails(max=20, latest=False) # Oldest 20 emails + +**Reading Email at an ordinal** +------------------------------- + +Although rare in usage, but if you want to read an email at a paticular ordinal position (human counting), you can do that as well: + +Remember that ordinal numbers are from oldest to newest. + +You can do this by setting **latest** parameter to False. + +.. code-block:: python + + email = mailbox.email_at(7) # 7th oldest email + + +**Filtering Emails** +-------------------- + +You can further filter latest/oldest emails using one or more of the provided filters in **emails** as well as **latest** methods. + +If you use more than one filter, all of them should be true for an email to be included in results. + + +**Filtering** Emails by **Sender** +---------------------------------- + +You can filter emails by sender. Case insensitive and partial match is used by Arjuna. + +.. code-block:: python + + mailbox.latest(sender="email or partial text") + mailbox.emails(sender="email or partial text") + +**Filtering** Emails by **Subject** +----------------------------------- + +You can filter emails by subject. Case insensitive and partial match is used by Arjuna. + +.. code-block:: python + + mailbox.latest(subject="full or partial subject line") + mailbox.emails(subject="full or partial subject line") + +**Filtering** Emails by **Content** +----------------------------------- + +You can filter emails by content. Case insensitive and partial match is used by Arjuna. + +.. code-block:: python + + mailbox.latest(content="partial content") + mailbox.emails(content="partial content") + +**Reading New Emails - The Challenges** +--------------------------------------- + +This is the trickiest part in automation of reading emails. + +A mailbox is a dynamic entity. + * It can receive emails which are not triggered by activity in a test. + * There might be a time delay in receipt of email in mailbox from the time an email-sending activity is triggered by a test. + +For the above reasons, you need two things: + * A state-aware code to differentiate existing and new emails. + * Polling mechanism to wait for new emails to arrive. + +**Reading New Emails - One Time** +--------------------------------- + +Consider the following simple scenario: + * Connect to mail server + * Connect to mailbox + * Trigger email sending actvitiy + * Read new emails (one time) + * Process the emails as per your requirement + * Quit the server + +For such one time activity and reading of new emails, Arjuna automatically handles the state for you. + +.. code-block:: python + + # Stage 1 - Connect to mailbox + server = EmailServer.imap() # Connect to server + mailbox = server.get_mailbox() # Get access to Inbox + + # Stage 2 - Trigger one or more events for sending email(s) + + # Stage 3 - Read emails + emails = mailbox.new_emails() + + # Stage 4 - Process the emails + + # Stage 5 - Quit the server + server.quit() + +In the above code: + * At end of stage 1, Arjuna automatically saves the current mailbox state. + * At stage 3, Arjuna triggers a dynamic wait for new emails and returns only new emails. + +**Reading New Emails - Multiple Times** +--------------------------------------- + +Consider the following scenario: + * Connect to mail server + * Connect to mailbox + * Trigger email sending actvitiy + * Read new emails (one time) + * Trigger email sending actvitiy + * Read new emails (one time) + * Trigger email sending actvitiy + * Read new emails (one time) + * ... + * Quit the server + +For this kind of successive new email reading activity, you will need to ask Arjuna to save state explicitly using the **save_state** call. + +.. code-block:: python + + # Connect to mailbox + server = EmailServer.imap() # Connect to server + mailbox = server.get_mailbox() # Get access to Inbox + mailbox.save_state() # Optional for first time. Included for completeness sake. + + # Trigger one or more events for sending email(s) + + # Read emails + emails = mailbox.new_emails() + + # Process the emails + + mailbox.save_state() + + # Trigger one or more events for sending email(s) + + # Read emails + emails = mailbox.new_emails() + + # Process the emails + + mailbox.save_state() + + # Trigger one or more events for sending email(s) + + # Read emails + emails = mailbox.new_emails() + + # Process the emails + + mailbox.save_state() + + # And so on... + + # Stage 5 - Quit the server + server.quit() + +**Processing Emails** +--------------------- + +Once you have got emails from the server using any of the above mentioned approaches, you would want to read the content of such emails or extract certain parts of an email. + +All of above mentioned email-fetching calls (apart from **email_at**) return an Arjuna **Emails** object. + +The **Emails** object is like a Python list, so you can do the following operations: + +.. code-block:: python + + emails.count() # Get Number of emails + len(emails) # Get Number of emails + + emails[index] # Get email at a particular index. + +However, in practice, you would want to loop over this object and then process individual emails. + +In each loop/iteration, you get Arjuna's **Email** object which is a very powerful abstraction to easily gt email meta-data and contents. + +.. code-block:: python + + for email in emails: + + # Do something about a given email + +**Inquire Email Meta-Data** +--------------------------- + +You can get various pieces of meta-data of an email using its properties and methods: + +.. code-block:: python + + email.sender # content of From field + email.recipient # content of To field + email.date # Received date + email.subject # Subject line + email.get_header("some-header-name") # Get data for any standard/custom header + +**Get Email Contents** +---------------------- + +An email typically contains a chain of contents. Arjuna provides these contents as a list of Arjuna's **Text** and **Html** objects. + +Attachments are not supported as of now. + +.. code-block:: python + + content_list = email.contents + +Depending on the type of content at a particular index, you can use the corresponding methods to process it further. + +**Extracting Links from the Email** +----------------------------------- + +As the most common scenario in email reading related automation is to extract links from the email, Arjuna's **Email** object provides built-in advanced methods for this use case. + +Consider the following common scenario from Web GUI automation: + * GUI Automation - In the test, you click a forgot password link and provide email address. + * Email Automation - You wait for the email to be got in the corresponding mailbox + * Email Automation - You extract the password reset link from latest email. + * GUI Automation - You go to the extracted link and finish password reset use case. + +You can extract all links from the content chain of an email by using its **links** property: + +.. code-block:: python + + email.links + +You can get unique links (remove duplicates) from the content chain of an email by using its **unique_links** property: + +.. code-block:: python + + email.unique_links + +You can also get all the unqiue links by filtering based on a partial content of the link: + +.. code-block:: python + + email.find_link(contains="somedomain") # Get first link based on its partial link text. + email.find_links(contains="somedomain") # Get all links based on its partial link text. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/index.rst b/docs/source/index.rst index a60663d4..bea794dc 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -206,6 +206,26 @@ HTTP Automation * :py:class:`OAuthClientGrantService ` * :py:class:`OAuthImplicitGrantService ` +HTTP Automation +=============== +* :py:class:`Http ` +* :py:class:`HttpService` +* :py:class:`HttpRequest ` +* :py:class:`HttpResponse ` +* OAuth Support + * :py:class:`OAuthService ` + * :py:class:`OAuthClientGrantService ` + * :py:class:`OAuthImplicitGrantService ` + +E-Mail Automation +================= +* :py:class:`EmailServer ` +* :py:class:`ImapServer ` +* :py:class:`MailBox ` +* :py:class:`MailBox ` +* :py:class:`Emails ` +* :py:class:`Email ` + Reporting Protocols =================== @@ -316,6 +336,8 @@ Arjuna Exceptions * :py:class:`HttpConnectError ` * :py:class:`HttpSendError ` * :py:class:`HttpUnexpectedStatusCodeError ` + * :py:class:`SEAMfulActionFileError ` + * :py:class:`SEAMfulMessageFileError ` ****************** Indices and tables