X-Git-Url: https://git.notmuchmail.org/git?p=notmuch;a=blobdiff_plain;f=bindings%2Fpython%2Fnotmuch%2Fmessage.py;h=6e32b5f7ada2d825d4cd6c23403f6b6b1271691f;hp=b291f9fa4e8748c87b2c36912e33e47726a432f3;hb=60ddce8a161772583e8d223498997ee866d04ede;hpb=a1442952d4d7fad8b7612502802ee346ac8fd349 diff --git a/bindings/python/notmuch/message.py b/bindings/python/notmuch/message.py index b291f9fa..6e32b5f7 100644 --- a/bindings/python/notmuch/message.py +++ b/bindings/python/notmuch/message.py @@ -12,269 +12,37 @@ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License -along with notmuch. If not, see . +along with notmuch. If not, see . -Copyright 2010 Sebastian Spaeth ' +Copyright 2010 Sebastian Spaeth Jesse Rosenthal """ -from ctypes import c_char_p, c_long, c_uint, c_int +from ctypes import c_char_p, c_long, c_uint, c_int, POINTER, byref from datetime import date -from notmuch.globals import ( +from .globals import ( nmlib, Enum, _str, Python3StringMixIn, - STATUS, - NotmuchError, - NullPointerError, - NotInitializedError, NotmuchTagsP, NotmuchMessageP, NotmuchMessagesP, + NotmuchMessagePropertiesP, NotmuchFilenamesP, ) -from notmuch.tag import Tags -from notmuch.filename import Filenames -import sys -import email -try: - import simplejson as json -except ImportError: - import json - - -class Messages(object): - """Represents a list of notmuch messages - - This object provides an iterator over a list of notmuch messages - (Technically, it provides a wrapper for the underlying - *notmuch_messages_t* structure). Do note that the underlying library - only provides a one-time iterator (it cannot reset the iterator to - the start). Thus iterating over the function will "exhaust" the list - of messages, and a subsequent iteration attempt will raise a - :exc:`NotInitializedError`. If you need to - re-iterate over a list of messages you will need to retrieve a new - :class:`Messages` object or cache your :class:`Message`\s in a list - via:: - - msglist = list(msgs) - - You can store and reuse the single :class:`Message` objects as often - as you want as long as you keep the parent :class:`Messages` object - around. (Due to hierarchical memory allocation, all derived - :class:`Message` objects will be invalid when we delete the parent - :class:`Messages` object, even if it was already exhausted.) So - this works:: - - db = Database() - msgs = Query(db,'').search_messages() #get a Messages() object - msglist = list(msgs) - - # msgs is "exhausted" now and msgs.next() will raise an exception. - # However it will be kept alive until all retrieved Message() - # objects are also deleted. If you do e.g. an explicit del(msgs) - # here, the following lines would fail. - - # You can reiterate over *msglist* however as often as you want. - # It is simply a list with :class:`Message`s. - - print (msglist[0].get_filename()) - print (msglist[1].get_filename()) - print (msglist[0].get_message_id()) - - - As :class:`Message` implements both __hash__() and __cmp__(), it is - possible to make sets out of :class:`Messages` and use set - arithmetic (this happens in python and will of course be *much* - slower than redoing a proper query with the appropriate filters:: - - s1, s2 = set(msgs1), set(msgs2) - s.union(s2) - s1 -= s2 - ... - - Be careful when using set arithmetic between message sets derived - from different Databases (ie the same database reopened after - messages have changed). If messages have added or removed associated - files in the meantime, it is possible that the same message would be - considered as a different object (as it points to a different file). - """ - - #notmuch_messages_get - _get = nmlib.notmuch_messages_get - _get.argtypes = [NotmuchMessagesP] - _get.restype = NotmuchMessageP - - _collect_tags = nmlib.notmuch_messages_collect_tags - _collect_tags.argtypes = [NotmuchMessagesP] - _collect_tags.restype = NotmuchTagsP - - def __init__(self, msgs_p, parent=None): - """ - :param msgs_p: A pointer to an underlying *notmuch_messages_t* - structure. These are not publically exposed, so a user - will almost never instantiate a :class:`Messages` object - herself. They are usually handed back as a result, - e.g. in :meth:`Query.search_messages`. *msgs_p* must be - valid, we will raise an :exc:`NullPointerError` if it is - `None`. - :type msgs_p: :class:`ctypes.c_void_p` - :param parent: The parent object - (ie :class:`Query`) these tags are derived from. It saves - a reference to it, so we can automatically delete the db - object once all derived objects are dead. - :TODO: Make the iterator work more than once and cache the tags in - the Python object.(?) - """ - if not msgs_p: - raise NullPointerError() - - self._msgs = msgs_p - #store parent, so we keep them alive as long as self is alive - self._parent = parent - - def collect_tags(self): - """Return the unique :class:`Tags` in the contained messages - - :returns: :class:`Tags` - :exceptions: :exc:`NotInitializedError` if not init'ed - - .. note:: - - :meth:`collect_tags` will iterate over the messages and therefore - will not allow further iterations. - """ - if not self._msgs: - raise NotInitializedError() - - # collect all tags (returns NULL on error) - tags_p = Messages._collect_tags(self._msgs) - #reset _msgs as we iterated over it and can do so only once - self._msgs = None - - if tags_p == None: - raise NullPointerError() - return Tags(tags_p, self) - - def __iter__(self): - """ Make Messages an iterator """ - return self - - _valid = nmlib.notmuch_messages_valid - _valid.argtypes = [NotmuchMessagesP] - _valid.restype = bool - - _move_to_next = nmlib.notmuch_messages_move_to_next - _move_to_next.argtypes = [NotmuchMessagesP] - _move_to_next.restype = None - - def __next__(self): - if not self._msgs: - raise NotInitializedError() - - if not self._valid(self._msgs): - self._msgs = None - raise StopIteration - - msg = Message(Messages._get(self._msgs), self) - self._move_to_next(self._msgs) - return msg - next = __next__ # python2.x iterator protocol compatibility - - def __nonzero__(self): - """ - :return: True if there is at least one more thread in the - Iterator, False if not.""" - return self._msgs is not None and \ - self._valid(self._msgs) > 0 - - _destroy = nmlib.notmuch_messages_destroy - _destroy.argtypes = [NotmuchMessagesP] - _destroy.restype = None - - def __del__(self): - """Close and free the notmuch Messages""" - if self._msgs is not None: - self._destroy(self._msgs) - - def format_messages(self, format, indent=0, entire_thread=False): - """Formats messages as needed for 'notmuch show'. - - :param format: A string of either 'text' or 'json'. - :param indent: A number indicating the reply depth of these messages. - :param entire_thread: A bool, indicating whether we want to output - whole threads or only the matching messages. - :return: a list of lines - """ - result = list() - - if format.lower() == "text": - set_start = "" - set_end = "" - set_sep = "" - elif format.lower() == "json": - set_start = "[" - set_end = "]" - set_sep = ", " - else: - raise TypeError("format must be either 'text' or 'json'") - - first_set = True - - result.append(set_start) - - # iterate through all toplevel messages in this thread - for msg in self: - # if not msg: - # break - if not first_set: - result.append(set_sep) - first_set = False - - result.append(set_start) - match = msg.is_match() - next_indent = indent - - if (match or entire_thread): - if format.lower() == "text": - result.append(msg.format_message_as_text(indent)) - else: - result.append(msg.format_message_as_json(indent)) - next_indent = indent + 1 - - # get replies and print them also out (if there are any) - replies = msg.get_replies().format_messages(format, next_indent, entire_thread) - if replies: - result.append(set_sep) - result.extend(replies) - - result.append(set_end) - result.append(set_end) - - return result - - def print_messages(self, format, indent=0, entire_thread=False, handle=sys.stdout): - """Outputs messages as needed for 'notmuch show' to a file like object. - - :param format: A string of either 'text' or 'json'. - :param handle: A file like object to print to (default is sys.stdout). - :param indent: A number indicating the reply depth of these messages. - :param entire_thread: A bool, indicating whether we want to output - whole threads or only the matching messages. - """ - handle.write(''.join(self.format_messages(format, indent, entire_thread))) - - -class EmptyMessagesResult(Messages): - def __init__(self, parent): - self._msgs = None - self._parent = parent +from .errors import ( + STATUS, + NotmuchError, + NullPointerError, + NotInitializedError, +) +from .tag import Tags +from .filenames import Filenames - def __next__(self): - raise StopIteration() - next = __next__ +import email +import sys class Message(Python3StringMixIn): @@ -346,6 +114,36 @@ class Message(Python3StringMixIn): _maildir_flags_to_tags.argtypes = [NotmuchMessageP] _maildir_flags_to_tags.restype = c_int + """notmuch_message_get_property""" + _get_property = nmlib.notmuch_message_get_property + _get_property.argtypes = [NotmuchMessageP, c_char_p, POINTER(c_char_p)] + _get_property.restype = c_int + + """notmuch_message_get_properties""" + _get_properties = nmlib.notmuch_message_get_properties + _get_properties.argtypes = [NotmuchMessageP, c_char_p, c_int] + _get_properties.restype = NotmuchMessagePropertiesP + + """notmuch_message_properties_valid""" + _properties_valid = nmlib.notmuch_message_properties_valid + _properties_valid.argtypes = [NotmuchMessagePropertiesP] + _properties_valid.restype = bool + + """notmuch_message_properties_value""" + _properties_value = nmlib.notmuch_message_properties_value + _properties_value.argtypes = [NotmuchMessagePropertiesP] + _properties_value.restype = c_char_p + + """notmuch_message_properties_key""" + _properties_key = nmlib.notmuch_message_properties_key + _properties_key.argtypes = [NotmuchMessagePropertiesP] + _properties_key.restype = c_char_p + + """notmuch_message_properties_move_to_next""" + _properties_move_to_next = nmlib.notmuch_message_properties_move_to_next + _properties_move_to_next.argtypes = [NotmuchMessagePropertiesP] + _properties_move_to_next.restype = None + #Constants: Flags that can be set/get with set_flag FLAG = Enum(['MATCH']) @@ -370,7 +168,7 @@ class Message(Python3StringMixIn): """Returns the message ID :returns: String with a message ID - :exception: :exc:`NotInitializedError` if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ if not self._msg: @@ -387,7 +185,7 @@ class Message(Python3StringMixIn): message belongs to a single thread. :returns: String with a thread ID - :exception: :exc:`NotInitializedError` if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ if not self._msg: @@ -410,7 +208,7 @@ class Message(Python3StringMixIn): an empty Messages iterator. :returns: :class:`Messages`. - :exception: :exc:`NotInitializedError` if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ if not self._msg: @@ -418,6 +216,8 @@ class Message(Python3StringMixIn): msgs_p = Message._get_replies(self._msg) + from .messages import Messages, EmptyMessagesResult + if not msgs_p: return EmptyMessagesResult(self) @@ -432,7 +232,7 @@ class Message(Python3StringMixIn): :returns: A time_t timestamp. :rtype: c_unit64 - :exception: :exc:`NotInitializedError` if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ if not self._msg: @@ -454,7 +254,7 @@ class Message(Python3StringMixIn): :returns: The header value as string :raises: :exc:`NotInitializedError` if the message is not initialized - :raises: :exc:`NullPointerError` if any error occured + :raises: :exc:`NullPointerError` if any error occurred """ if not self._msg: raise NotInitializedError() @@ -469,7 +269,7 @@ class Message(Python3StringMixIn): """Returns the file path of the message file :returns: Absolute file path & name of the message file - :exception: :exc:`NotInitializedError` if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ if not self._msg: @@ -487,7 +287,7 @@ class Message(Python3StringMixIn): files_p = Message._get_filenames(self._msg) - return Filenames(files_p, self).as_generator() + return Filenames(files_p, self) def get_flag(self, flag): """Checks whether a specific flag is set for this message @@ -499,7 +299,7 @@ class Message(Python3StringMixIn): :param flag: One of the :attr:`Message.FLAG` values (currently only *Message.FLAG.MATCH* :returns: An unsigned int (0/1), indicating whether the flag is set. - :exception: :exc:`NotInitializedError` if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ if not self._msg: @@ -513,8 +313,7 @@ class Message(Python3StringMixIn): *Message.FLAG.MATCH* :param value: A bool indicating whether to set or unset the flag. - :returns: Nothing - :exception: :exc:`NotInitializedError` if the message + :raises: :exc:`NotInitializedError` if the message is not initialized. """ if not self._msg: @@ -527,13 +326,13 @@ class Message(Python3StringMixIn): :returns: A :class:`Tags` iterator. :raises: :exc:`NotInitializedError` if the message is not initialized - :raises: :exc:`NullPointerError` if any error occured + :raises: :exc:`NullPointerError` if any error occurred """ if not self._msg: raise NotInitializedError() tags_p = Message._get_tags(self._msg) - if tags_p == None: + if not tags_p: raise NullPointerError() return Tags(tags_p, self) @@ -665,6 +464,55 @@ class Message(Python3StringMixIn): _freeze.argtypes = [NotmuchMessageP] _freeze.restype = c_uint + def get_property(self, prop): + """ Retrieve the value for a single property key + + :param prop: The name of the property to get. + :returns: String with the property value or None if there is no such + key. In the case of multiple values for the given key, the + first one is retrieved. + :raises: :exc:`NotInitializedError` if message has not been + initialized + """ + if not self._msg: + raise NotInitializedError() + + value = c_char_p() + status = Message._get_property(self._msg, _str(prop), byref(value)) + if status != 0: + raise NotmuchError(status) + + if value is None or value.value is None: + return None + return value.value.decode('utf-8') + + def get_properties(self, prop="", exact=False): + """ Get the properties of the message, returning a generator of + name, value pairs. + + The generator will yield once per value. There might be more than one + value on each name, so the generator might yield the same name several + times. + + :param prop: The name of the property to get. Otherwise it will return + the full list of properties of the message. + :param exact: if True, require exact match with key. Otherwise + treat as prefix. + :yields: Each property values as a pair of `name, value` + :ytype: pairs of str + :raises: :exc:`NotInitializedError` if message has not been + initialized + """ + if not self._msg: + raise NotInitializedError() + + properties = Message._get_properties(self._msg, _str(prop), exact) + while Message._properties_valid(properties): + key = Message._properties_key(properties) + value = Message._properties_value(properties) + yield key.decode("utf-8"), value.decode("utf-8") + Message._properties_move_to_next(properties) + def freeze(self): """Freezes the current state of 'message' within the database @@ -799,13 +647,13 @@ class Message(Python3StringMixIn): logical OR operator.) As a convenience, you can set the sync_maildir_flags parameter in - :meth:`Database.add_message` to implicitly call this. + :meth:`Database.index_file` to implicitly call this. :returns: a :class:`STATUS`. In short, you want to see notmuch.STATUS.SUCCESS here. See there for details.""" if not self._msg: raise NotInitializedError() - return Message._tags_to_maildir_flags(self._msg) + return Message._maildir_flags_to_tags(self._msg) def __repr__(self): """Represent a Message() object by str()""" @@ -820,8 +668,11 @@ class Message(Python3StringMixIn): def get_message_parts(self): """Output like notmuch show""" - fp = open(self.get_filename()) - email_msg = email.message_from_file(fp) + fp = open(self.get_filename(), 'rb') + if sys.version_info[0] < 3: + email_msg = email.message_from_file(fp) + else: + email_msg = email.message_from_binary_file(fp) fp.close() out = [] @@ -839,110 +690,6 @@ class Message(Python3StringMixIn): out_part = parts[(num - 1)] return out_part.get_payload(decode=True) - def format_message_internal(self): - """Create an internal representation of the message parts, - which can easily be output to json, text, or another output - format. The argument match tells whether this matched a - query.""" - output = {} - output["id"] = self.get_message_id() - output["match"] = self.is_match() - output["filename"] = self.get_filename() - output["tags"] = list(self.get_tags()) - - headers = {} - for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]: - headers[h] = self.get_header(h) - output["headers"] = headers - - body = [] - parts = self.get_message_parts() - for i in xrange(len(parts)): - msg = parts[i] - part_dict = {} - part_dict["id"] = i + 1 - # We'll be using this is a lot, so let's just get it once. - cont_type = msg.get_content_type() - part_dict["content-type"] = cont_type - # NOTE: - # Now we emulate the current behaviour, where it ignores - # the html if there's a text representation. - # - # This is being worked on, but it will be easier to fix - # here in the future than to end up with another - # incompatible solution. - disposition = msg["Content-Disposition"] - if disposition and disposition.lower().startswith("attachment"): - part_dict["filename"] = msg.get_filename() - else: - if cont_type.lower() == "text/plain": - part_dict["content"] = msg.get_payload() - elif (cont_type.lower() == "text/html" and - i == 0): - part_dict["content"] = msg.get_payload() - body.append(part_dict) - - output["body"] = body - - return output - - def format_message_as_json(self, indent=0): - """Outputs the message as json. This is essentially the same - as python's dict format, but we run it through, just so we - don't have to worry about the details.""" - return json.dumps(self.format_message_internal()) - - def format_message_as_text(self, indent=0): - """Outputs it in the old-fashioned notmuch text form. Will be - easy to change to a new format when the format changes.""" - - format = self.format_message_internal() - output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \ - % (format['id'], indent, format['match'], format['filename']) - output += "\n\fheader{" - - #Todo: this date is supposed to be prettified, as in the index. - output += "\n%s (%s) (" % (format["headers"]["From"], - format["headers"]["Date"]) - output += ", ".join(format["tags"]) - output += ")" - - output += "\nSubject: %s" % format["headers"]["Subject"] - output += "\nFrom: %s" % format["headers"]["From"] - output += "\nTo: %s" % format["headers"]["To"] - if format["headers"]["Cc"]: - output += "\nCc: %s" % format["headers"]["Cc"] - if format["headers"]["Bcc"]: - output += "\nBcc: %s" % format["headers"]["Bcc"] - output += "\nDate: %s" % format["headers"]["Date"] - output += "\n\fheader}" - - output += "\n\fbody{" - - parts = format["body"] - parts.sort(key=lambda x: x['id']) - for p in parts: - if not "filename" in p: - output += "\n\fpart{ " - output += "ID: %d, Content-type: %s\n" % (p["id"], - p["content-type"]) - if "content" in p: - output += "\n%s\n" % p["content"] - else: - output += "Non-text part: %s\n" % p["content-type"] - output += "\n\fpart}" - else: - output += "\n\fattachment{ " - output += "ID: %d, Content-type:%s\n" % (p["id"], - p["content-type"]) - output += "Attachment: %s\n" % p["filename"] - output += "\n\fattachment}\n" - - output += "\n\fbody}\n" - output += "\n\fmessage}" - - return output - def __hash__(self): """Implement hash(), so we can use Message() sets""" file = self.get_filename() @@ -970,5 +717,5 @@ class Message(Python3StringMixIn): def __del__(self): """Close and free the notmuch Message""" - if self._msg is not None: + if self._msg: self._destroy(self._msg)