diff --git a/README.md b/README.md index 1d6e209..7f4678f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ message, attributes, attachments = logbook.read(23) ```python # Create new message with some text, attributes (dict of attributes + kwargs) and attachments -new_msg_id = logbook.post('This is message text', attributes=dict_of_attributes, attachments=list_of_attachments, attribute_as_param='value') +new_msg_id = logbook.post('This is message text', attributes=dict_of_attributes, attachments=list_of_attachments, + attribute_as_param='value') ``` What attributes are required is determined by the configuration of the elog server (keywork `Required Attributes`). @@ -76,14 +77,16 @@ new_msg_id = logbook.post('This is message text', author='me', type='Routine') ```python # Reply to message with ID=23 -new_msg_id = logbook.post('This is a reply', msg_id=23, reply=True, attributes=dict_of_attributes, attachments=list_of_attachments, attribute_as_param='value') +new_msg_id = logbook.post('This is a reply', msg_id=23, reply=True, attributes=dict_of_attributes, + attachments=list_of_attachments, attribute_as_param='value') ``` ## Edit Message ```python # Edit message with ID=23. Changed message text, some attributes (dict of edited attributes + kwargs) and new attachments -edited_msg_id = logbook.post('This is new message text', msg_id=23, attributes=dict_of_changed_attributes, attachments=list_of_new_attachments, attribute_as_param='new value') +edited_msg_id = logbook.post('This is new message text', msg_id=23, attributes=dict_of_changed_attributes, + attachments=list_of_new_attachments, attribute_as_param='new value') ``` ## Search Messages @@ -92,7 +95,7 @@ edited_msg_id = logbook.post('This is new message text', msg_id=23, attributes=d # Search for text in messages or specify attributes for search, returns list of message ids logbook.search('Hello World') logbook.search('Hello World', n_results=20, scope='attribname') -logbook.search({'attribname' : 'Hello World', ... }) +logbook.search({'attribname': 'Hello World', ...}) ``` ## Delete Message (and all its replies) diff --git a/elog/logbook.py b/elog/logbook.py index 99f0396..45d387a 100644 --- a/elog/logbook.py +++ b/elog/logbook.py @@ -3,6 +3,7 @@ import os import builtins import re +import sys from elog.logbook_exceptions import * from datetime import datetime @@ -105,8 +106,8 @@ def __init__(self, hostname, logbook='', port=None, user=None, password=None, su self._user = user self._password = _handle_pswd(password, encrypt_pwd) - def post(self, message, msg_id=None, reply=False, attributes=None, attachments=None, - suppress_email_notification=False, encoding=None, **kwargs): + def post(self, message, msg_id=None, reply=False, attributes=None, attachments=None, + suppress_email_notification=False, encoding=None, timeout=None, **kwargs): """ Posts message to the logbook. If msg_id is not specified new message will be created, otherwise existing message will be edited, or a reply (if reply=True) to it will be created. This method returns the msg_id @@ -123,9 +124,11 @@ def post(self, message, msg_id=None, reply=False, attributes=None, attachments=N - paths to the files All items will be appended as attachment to the elog entry. In case of unknown attachment an exception LogbookInvalidAttachment will be raised. + :param suppress_email_notification: If set to True or 1, E-Mail notification will be suppressed, defaults to False. :param encoding: Defines encoding of the message. Can be: 'plain' -> plain text, 'html'->html-text, 'ELCode' --> elog formatting syntax - :param suppress_email_notification: If set to True or 1, E-Mail notification will be suppressed, defaults to False. + :param timeout: Define the timeout to be used by the post request. Its value is directly passed to the requests + post. Use None to disable the request timeout. :param kwargs: Anything in the kwargs will be interpreted as attribute. e.g.: logbook.post('Test text', Author='Rok Vintar), "Author" will be sent as an attribute. If named same as one of the attributes defined in "attributes", kwargs will have priority. @@ -194,17 +197,21 @@ def post(self, message, msg_id=None, reply=False, attributes=None, attachments=N # Make requests module think that Text is a "file". This is the only way to force requests to send data as # multipart/form-data even if there are no attachments. Elog understands only multipart/form-data - files_to_attach.append(('Text', ('', message))) + files_to_attach.append(('Text', ('', message.encode('iso-8859-1')))) # Base attributes are common to all messages self._add_base_msg_attributes(attributes_to_edit) # Keys in attributes cannot have certain characters like whitespaces or dashes for the http request attributes_to_edit = _replace_special_characters_in_attribute_keys(attributes_to_edit) + + # All string values in the attributes must be encoded in latin1 + attributes_to_edit = _encode_values(attributes_to_edit) try: response = requests.post(self._url, data=attributes_to_edit, files=files_to_attach, allow_redirects=False, - verify=False) + verify=False, timeout=timeout) + # Validate response. Any problems will raise an Exception. resp_message, resp_headers, resp_msg_id = _validate_response(response) @@ -213,6 +220,12 @@ def post(self, message, msg_id=None, reply=False, attributes=None, attachments=N if hasattr(file_like_object, 'close'): file_like_object.close() + except requests.Timeout as e: + # Catch here a timeout o the post request. + # Raise the logbook excetion and let the user handle it + raise LogbookServerTimeout('{0} method cannot be completed because of a network timeout:\n'+ + '{1}'.format(sys._getframe().f_code.co_name, e)) + except requests.RequestException as e: # Check if message on server. self._check_if_message_on_server(msg_id) # raises exceptions if no message or no response from server @@ -226,7 +239,7 @@ def post(self, message, msg_id=None, reply=False, attributes=None, attachments=N raise LogbookInvalidMessageID('Invalid message ID: ' + str(resp_msg_id) + ' returned') return resp_msg_id - def read(self, msg_id): + def read(self, msg_id, timeout=None): """ Reads message from the logbook server and returns tuple of (message, attributes, attachments) where: message: string with message body @@ -234,6 +247,7 @@ def read(self, msg_id): attachments: list of urls to attachments on the logbook server :param msg_id: ID of the message to be read + :param timeout: The timeout value to be passed to the get request. :return: message, attributes, attachments """ @@ -244,11 +258,21 @@ def read(self, msg_id): try: self._check_if_message_on_server(msg_id) # raises exceptions if no message or no response from server response = requests.get(self._url + str(msg_id) + '?cmd=download', headers=request_headers, - allow_redirects=False, verify=False) + allow_redirects=False, verify=False, timeout=timeout) # Validate response. If problems Exception will be thrown. resp_message, resp_headers, resp_msg_id = _validate_response(response) + + except requests.Timeout as e: + + # Catch here a timeout o the post request. + + # Raise the logbook excetion and let the user handle it + + raise LogbookServerTimeout('{0} method cannot be completed because of a network timeout:\n' + + '{1}'.format(sys._getframe().f_code.co_name, e)) + except requests.RequestException as e: # If here: message is on server but cannot be downloaded (should never happen) raise LogbookServerProblem('Cannot access logbook server to read the message with ID: ' + str(msg_id) + @@ -258,7 +282,7 @@ def read(self, msg_id): attributes = dict() attachments = list() - returned_msg = resp_message.decode('utf-8', 'ignore').splitlines() + returned_msg = resp_message.decode('iso-8859-1', 'ignore').splitlines() delimiter_idx = returned_msg.index('========================================') message = '\n'.join(returned_msg[delimiter_idx + 1:]) @@ -280,12 +304,13 @@ def read(self, msg_id): return message, attributes, attachments - def delete(self, msg_id): + def delete(self, msg_id, timeout=None): """ Deletes message thread (!!!message + all replies!!!) from logbook. It also deletes all of attachments of corresponding messages from the server. :param msg_id: message to be deleted + :param timeout: timeout value to be passed to the get request :return: """ @@ -297,10 +322,16 @@ def delete(self, msg_id): self._check_if_message_on_server(msg_id) # check if something to delete response = requests.get(self._url + str(msg_id) + '?cmd=Delete&confirm=Yes', headers=request_headers, - allow_redirects=False, verify=False) + allow_redirects=False, verify=False, timeout=timeout) _validate_response(response) # raises exception if any other error identified + except requests.Timeout as e: + # Catch here a timeout o the post request. + # Raise the logbook excetion and let the user handle it + raise LogbookServerTimeout('{0} method cannot be completed because of a network timeout:\n'+ + '{1}'.format(sys._getframe().f_code.co_name, e)) + except requests.RequestException as e: # If here: message is on server but cannot be downloaded (should never happen) raise LogbookServerProblem('Cannot access logbook server to delete the message with ID: ' + str(msg_id) + @@ -312,10 +343,12 @@ def delete(self, msg_id): if response.status_code == 200: raise LogbookServerProblem('Cannot process delete command (only logbooks in English supported).') - def search(self, search_term, n_results=20, scope="subtext"): + def search(self, search_term, n_results=20, scope="subtext", timeout=None): """ Searches the logbook and returns the message ids. + :param timeout: timeout value to be passed to the get request + """ request_headers = dict() if self._user or self._password: @@ -343,12 +376,18 @@ def search(self, search_term, n_results=20, scope="subtext"): try: response = requests.get(self._url, params=params, headers=request_headers, - allow_redirects=False, verify=False) + allow_redirects=False, verify=False, timeout=timeout) # Validate response. If problems Exception will be thrown. _validate_response(response) resp_message = response + except requests.Timeout as e: + # Catch here a timeout o the post request. + # Raise the logbook excetion and let the user handle it + raise LogbookServerTimeout('{0} method cannot be completed because of a network timeout:\n'+ + '{1}'.format(sys._getframe().f_code.co_name, e)) + except requests.RequestException as e: # If here: message is on server but cannot be downloaded (should never happen) raise LogbookServerProblem('Cannot access logbook server to read message ids ' @@ -360,26 +399,32 @@ def search(self, search_term, n_results=20, scope="subtext"): message_ids = [int(m.split("/")[-1]) for m in message_ids] return message_ids - def get_last_message_id(self): - ids = self.get_message_ids() + def get_last_message_id(self, timeout=None): + ids = self.get_message_ids(timeout) if len(ids) > 0: return ids[0] else: return None - def get_message_ids(self): + def get_message_ids(self, timeout=None): request_headers = dict() if self._user or self._password: request_headers['Cookie'] = self._make_user_and_pswd_cookie() try: response = requests.get(self._url + 'page', headers=request_headers, - allow_redirects=False, verify=False) + allow_redirects=False, verify=False, timeout=timeout) # Validate response. If problems Exception will be thrown. _validate_response(response) resp_message = response + except requests.Timeout as e: + # Catch here a timeout o the post request. + # Raise the logbook excetion and let the user handle it + raise LogbookServerTimeout('{0} method cannot be completed because of a network timeout:\n'+ + '{1}'.format(sys._getframe().f_code.co_name, e)) + except requests.RequestException as e: # If here: message is on server but cannot be downloaded (should never happen) raise LogbookServerProblem('Cannot access logbook server to read message ids ' @@ -391,11 +436,12 @@ def get_message_ids(self): message_ids = [int(m.split("/")[-1]) for m in message_ids] return message_ids - def _check_if_message_on_server(self, msg_id): + def _check_if_message_on_server(self, msg_id, timeout=None): """Try to load page for specific message. If there is a htm tag like then there is no such message. :param msg_id: ID of message to be checked + :params timeout: The value of timeout to be passed to the get request :return: """ @@ -404,7 +450,7 @@ def _check_if_message_on_server(self, msg_id): request_headers['Cookie'] = self._make_user_and_pswd_cookie() try: response = requests.get(self._url + str(msg_id), headers=request_headers, allow_redirects=False, - verify=False) + verify=False, timeout=timeout) # If there is no message code 200 will be returned (OK) and _validate_response will not recognise it # but there will be some error in the html code. @@ -416,6 +462,12 @@ def _check_if_message_on_server(self, msg_id): flags=re.DOTALL): raise LogbookInvalidMessageID('Message with ID: ' + str(msg_id) + ' does not exist on logbook.') + except requests.Timeout as e: + # Catch here a timeout o the post request. + # Raise the logbook excetion and let the user handle it + raise LogbookServerTimeout('{0} method cannot be completed because of a network timeout:\n'+ + '{1}'.format(sys._getframe().f_code.co_name, e)) + except requests.RequestException as e: raise LogbookServerProblem('No response from the logbook server.\nDetails: ' + '{0}'.format(e)) @@ -513,6 +565,22 @@ def _remove_reserved_attributes(attributes): attributes.pop('Attachment', None) attributes.pop('Text', None) # Remove this one because it will be send attachment like +def _encode_values(attributes): + """ + prepares a dictionary of the attributes with latin1 encoded string values. + + :param attributes: dictionary of attributes to ve encoded + :return: dictionary with encoded string attributes + """ + + encoded_attributes = {} + for key, value in attributes.items(): + if isinstance(value, str): + encoded_attributes[key] = value.encode('iso-8859-1') + else: + encoded_attributes[key] = value + return encoded_attributes + def _replace_special_characters_in_attribute_keys(attributes): """ diff --git a/elog/logbook_exceptions.py b/elog/logbook_exceptions.py index 64bea05..180f4d3 100644 --- a/elog/logbook_exceptions.py +++ b/elog/logbook_exceptions.py @@ -7,6 +7,8 @@ class LogbookAuthenticationError(LogbookError): """ Raise when problem with username and password.""" pass +class LogbookServerTimeout(LogbookError): + """ Raise when the request to the logbook server timeouts. """ class LogbookServerProblem(LogbookError): """ Raise when problem accessing logbook server.""" diff --git a/setup.py b/setup.py index c2766ed..3a1ea9e 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ README = open(os.path.join(here, 'README.md')).read() setup(name='py_elog', - version='1.3.13', + version='1.3.14', description="Python library to access Elog.", long_description=README, long_description_content_type="text/markdown", diff --git a/tests/test_logbook.py b/tests/test_logbook.py index c96e6fe..e0a087b 100644 --- a/tests/test_logbook.py +++ b/tests/test_logbook.py @@ -1,7 +1,7 @@ import unittest # import logging import elog - +from elog.logbook_exceptions import * @@ -11,32 +11,24 @@ class TestClass(unittest.TestCase): # TODO add description how to run the test docker container for testing elog_hostname = 'https://elog.psi.ch/elogs/Linux+Demo/' - + message = 'This message text is new' def test_get_message_ids(self): logbook = elog.open(self.elog_hostname) message_ids = logbook.get_message_ids() - print(len(message_ids)) - print(message_ids) - + def test_get_last_message_id(self): logbook = elog.open(self.elog_hostname) - msg_id = logbook.post('This is message text is new', attributes={'Author':'AB', 'Type':'Routine'}) + msg_id = logbook.post(self.message, attributes={'Author': 'AB', 'Type': 'Routine'}) message_id = logbook.get_last_message_id() - - print(msg_id) - print(message_id) self.assertEqual(msg_id, message_id, "Created message does not show up as last edited message") - - def test_read(self): + def test_get_last_message_id_with_short_timeout(self): logbook = elog.open(self.elog_hostname) - message, attributes, attachments = logbook.read(logbook.get_last_message_id()) - print(message) - self.assertEqual(message, 'This is message text is new', "Unable to retrieve message") - + self.assertRaises(LogbookServerTimeout, logbook.post, + self.message, attributes={'Author': 'AB', 'Type': 'Routine'}, timeout=0.01) def test_edit(self): logbook = elog.open(self.elog_hostname) @@ -45,17 +37,36 @@ def test_edit(self): def test_search(self): logbook = elog.open(self.elog_hostname) ids = logbook.search("message") - print(ids) def test_search_empty(self): logbook = elog.open(self.elog_hostname) ids = logbook.search("") - print(ids) def test_search_dict(self): logbook = elog.open(self.elog_hostname) ids = logbook.search({"Category": "Hardware"}) - print(ids) + + def test_post_special_characters(self): + logbook = elog.open(self.elog_hostname) + attributes = { 'Author' : 'Me', 'Type' : 'Other', 'Category' : 'General', + 'Subject' : 'This is a test of UTF-8 characters like èéöä'} + message = 'Just to be clear this is a general test using UTF-8 characters like èéöä.' + msg_id = logbook.post(message, reply=False, attributes=attributes, encoding='HTML') + read_msg, read_attr, read_att = logbook.read(msg_id) + + mess_ok = message == read_msg + attr_ok = True + for key in attributes: + if attributes[key] == read_attr[key]: + attr_ok = attr_ok and True + else: + attr_ok = attr_ok and False + + whole_test = attr_ok and mess_ok + + self.assertTrue(whole_test) + if __name__ == '__main__': unittest.main() + \ No newline at end of file