Home | History | Annotate | Download | only in python2.7
      1 """MH interface -- purely object-oriented (well, almost)
      2 
      3 Executive summary:
      4 
      5 import mhlib
      6 
      7 mh = mhlib.MH()         # use default mailbox directory and profile
      8 mh = mhlib.MH(mailbox)  # override mailbox location (default from profile)
      9 mh = mhlib.MH(mailbox, profile) # override mailbox and profile
     10 
     11 mh.error(format, ...)   # print error message -- can be overridden
     12 s = mh.getprofile(key)  # profile entry (None if not set)
     13 path = mh.getpath()     # mailbox pathname
     14 name = mh.getcontext()  # name of current folder
     15 mh.setcontext(name)     # set name of current folder
     16 
     17 list = mh.listfolders() # names of top-level folders
     18 list = mh.listallfolders() # names of all folders, including subfolders
     19 list = mh.listsubfolders(name) # direct subfolders of given folder
     20 list = mh.listallsubfolders(name) # all subfolders of given folder
     21 
     22 mh.makefolder(name)     # create new folder
     23 mh.deletefolder(name)   # delete folder -- must have no subfolders
     24 
     25 f = mh.openfolder(name) # new open folder object
     26 
     27 f.error(format, ...)    # same as mh.error(format, ...)
     28 path = f.getfullname()  # folder's full pathname
     29 path = f.getsequencesfilename() # full pathname of folder's sequences file
     30 path = f.getmessagefilename(n)  # full pathname of message n in folder
     31 
     32 list = f.listmessages() # list of messages in folder (as numbers)
     33 n = f.getcurrent()      # get current message
     34 f.setcurrent(n)         # set current message
     35 list = f.parsesequence(seq)     # parse msgs syntax into list of messages
     36 n = f.getlast()         # get last message (0 if no messagse)
     37 f.setlast(n)            # set last message (internal use only)
     38 
     39 dict = f.getsequences() # dictionary of sequences in folder {name: list}
     40 f.putsequences(dict)    # write sequences back to folder
     41 
     42 f.createmessage(n, fp)  # add message from file f as number n
     43 f.removemessages(list)  # remove messages in list from folder
     44 f.refilemessages(list, tofolder) # move messages in list to other folder
     45 f.movemessage(n, tofolder, ton)  # move one message to a given destination
     46 f.copymessage(n, tofolder, ton)  # copy one message to a given destination
     47 
     48 m = f.openmessage(n)    # new open message object (costs a file descriptor)
     49 m is a derived class of mimetools.Message(rfc822.Message), with:
     50 s = m.getheadertext()   # text of message's headers
     51 s = m.getheadertext(pred) # text of message's headers, filtered by pred
     52 s = m.getbodytext()     # text of message's body, decoded
     53 s = m.getbodytext(0)    # text of message's body, not decoded
     54 """
     55 from warnings import warnpy3k
     56 warnpy3k("the mhlib module has been removed in Python 3.0; use the mailbox "
     57             "module instead", stacklevel=2)
     58 del warnpy3k
     59 
     60 # XXX To do, functionality:
     61 # - annotate messages
     62 # - send messages
     63 #
     64 # XXX To do, organization:
     65 # - move IntSet to separate file
     66 # - move most Message functionality to module mimetools
     67 
     68 
     69 # Customizable defaults
     70 
     71 MH_PROFILE = '~/.mh_profile'
     72 PATH = '~/Mail'
     73 MH_SEQUENCES = '.mh_sequences'
     74 FOLDER_PROTECT = 0700
     75 
     76 
     77 # Imported modules
     78 
     79 import os
     80 import sys
     81 import re
     82 import mimetools
     83 import multifile
     84 import shutil
     85 from bisect import bisect
     86 
     87 __all__ = ["MH","Error","Folder","Message"]
     88 
     89 # Exported constants
     90 
     91 class Error(Exception):
     92     pass
     93 
     94 
     95 class MH:
     96     """Class representing a particular collection of folders.
     97     Optional constructor arguments are the pathname for the directory
     98     containing the collection, and the MH profile to use.
     99     If either is omitted or empty a default is used; the default
    100     directory is taken from the MH profile if it is specified there."""
    101 
    102     def __init__(self, path = None, profile = None):
    103         """Constructor."""
    104         if profile is None: profile = MH_PROFILE
    105         self.profile = os.path.expanduser(profile)
    106         if path is None: path = self.getprofile('Path')
    107         if not path: path = PATH
    108         if not os.path.isabs(path) and path[0] != '~':
    109             path = os.path.join('~', path)
    110         path = os.path.expanduser(path)
    111         if not os.path.isdir(path): raise Error, 'MH() path not found'
    112         self.path = path
    113 
    114     def __repr__(self):
    115         """String representation."""
    116         return 'MH(%r, %r)' % (self.path, self.profile)
    117 
    118     def error(self, msg, *args):
    119         """Routine to print an error.  May be overridden by a derived class."""
    120         sys.stderr.write('MH error: %s\n' % (msg % args))
    121 
    122     def getprofile(self, key):
    123         """Return a profile entry, None if not found."""
    124         return pickline(self.profile, key)
    125 
    126     def getpath(self):
    127         """Return the path (the name of the collection's directory)."""
    128         return self.path
    129 
    130     def getcontext(self):
    131         """Return the name of the current folder."""
    132         context = pickline(os.path.join(self.getpath(), 'context'),
    133                   'Current-Folder')
    134         if not context: context = 'inbox'
    135         return context
    136 
    137     def setcontext(self, context):
    138         """Set the name of the current folder."""
    139         fn = os.path.join(self.getpath(), 'context')
    140         f = open(fn, "w")
    141         f.write("Current-Folder: %s\n" % context)
    142         f.close()
    143 
    144     def listfolders(self):
    145         """Return the names of the top-level folders."""
    146         folders = []
    147         path = self.getpath()
    148         for name in os.listdir(path):
    149             fullname = os.path.join(path, name)
    150             if os.path.isdir(fullname):
    151                 folders.append(name)
    152         folders.sort()
    153         return folders
    154 
    155     def listsubfolders(self, name):
    156         """Return the names of the subfolders in a given folder
    157         (prefixed with the given folder name)."""
    158         fullname = os.path.join(self.path, name)
    159         # Get the link count so we can avoid listing folders
    160         # that have no subfolders.
    161         nlinks = os.stat(fullname).st_nlink
    162         if nlinks <= 2:
    163             return []
    164         subfolders = []
    165         subnames = os.listdir(fullname)
    166         for subname in subnames:
    167             fullsubname = os.path.join(fullname, subname)
    168             if os.path.isdir(fullsubname):
    169                 name_subname = os.path.join(name, subname)
    170                 subfolders.append(name_subname)
    171                 # Stop looking for subfolders when
    172                 # we've seen them all
    173                 nlinks = nlinks - 1
    174                 if nlinks <= 2:
    175                     break
    176         subfolders.sort()
    177         return subfolders
    178 
    179     def listallfolders(self):
    180         """Return the names of all folders and subfolders, recursively."""
    181         return self.listallsubfolders('')
    182 
    183     def listallsubfolders(self, name):
    184         """Return the names of subfolders in a given folder, recursively."""
    185         fullname = os.path.join(self.path, name)
    186         # Get the link count so we can avoid listing folders
    187         # that have no subfolders.
    188         nlinks = os.stat(fullname).st_nlink
    189         if nlinks <= 2:
    190             return []
    191         subfolders = []
    192         subnames = os.listdir(fullname)
    193         for subname in subnames:
    194             if subname[0] == ',' or isnumeric(subname): continue
    195             fullsubname = os.path.join(fullname, subname)
    196             if os.path.isdir(fullsubname):
    197                 name_subname = os.path.join(name, subname)
    198                 subfolders.append(name_subname)
    199                 if not os.path.islink(fullsubname):
    200                     subsubfolders = self.listallsubfolders(
    201                               name_subname)
    202                     subfolders = subfolders + subsubfolders
    203                 # Stop looking for subfolders when
    204                 # we've seen them all
    205                 nlinks = nlinks - 1
    206                 if nlinks <= 2:
    207                     break
    208         subfolders.sort()
    209         return subfolders
    210 
    211     def openfolder(self, name):
    212         """Return a new Folder object for the named folder."""
    213         return Folder(self, name)
    214 
    215     def makefolder(self, name):
    216         """Create a new folder (or raise os.error if it cannot be created)."""
    217         protect = pickline(self.profile, 'Folder-Protect')
    218         if protect and isnumeric(protect):
    219             mode = int(protect, 8)
    220         else:
    221             mode = FOLDER_PROTECT
    222         os.mkdir(os.path.join(self.getpath(), name), mode)
    223 
    224     def deletefolder(self, name):
    225         """Delete a folder.  This removes files in the folder but not
    226         subdirectories.  Raise os.error if deleting the folder itself fails."""
    227         fullname = os.path.join(self.getpath(), name)
    228         for subname in os.listdir(fullname):
    229             fullsubname = os.path.join(fullname, subname)
    230             try:
    231                 os.unlink(fullsubname)
    232             except os.error:
    233                 self.error('%s not deleted, continuing...' %
    234                           fullsubname)
    235         os.rmdir(fullname)
    236 
    237 
    238 numericprog = re.compile('^[1-9][0-9]*$')
    239 def isnumeric(str):
    240     return numericprog.match(str) is not None
    241 
    242 class Folder:
    243     """Class representing a particular folder."""
    244 
    245     def __init__(self, mh, name):
    246         """Constructor."""
    247         self.mh = mh
    248         self.name = name
    249         if not os.path.isdir(self.getfullname()):
    250             raise Error, 'no folder %s' % name
    251 
    252     def __repr__(self):
    253         """String representation."""
    254         return 'Folder(%r, %r)' % (self.mh, self.name)
    255 
    256     def error(self, *args):
    257         """Error message handler."""
    258         self.mh.error(*args)
    259 
    260     def getfullname(self):
    261         """Return the full pathname of the folder."""
    262         return os.path.join(self.mh.path, self.name)
    263 
    264     def getsequencesfilename(self):
    265         """Return the full pathname of the folder's sequences file."""
    266         return os.path.join(self.getfullname(), MH_SEQUENCES)
    267 
    268     def getmessagefilename(self, n):
    269         """Return the full pathname of a message in the folder."""
    270         return os.path.join(self.getfullname(), str(n))
    271 
    272     def listsubfolders(self):
    273         """Return list of direct subfolders."""
    274         return self.mh.listsubfolders(self.name)
    275 
    276     def listallsubfolders(self):
    277         """Return list of all subfolders."""
    278         return self.mh.listallsubfolders(self.name)
    279 
    280     def listmessages(self):
    281         """Return the list of messages currently present in the folder.
    282         As a side effect, set self.last to the last message (or 0)."""
    283         messages = []
    284         match = numericprog.match
    285         append = messages.append
    286         for name in os.listdir(self.getfullname()):
    287             if match(name):
    288                 append(name)
    289         messages = map(int, messages)
    290         messages.sort()
    291         if messages:
    292             self.last = messages[-1]
    293         else:
    294             self.last = 0
    295         return messages
    296 
    297     def getsequences(self):
    298         """Return the set of sequences for the folder."""
    299         sequences = {}
    300         fullname = self.getsequencesfilename()
    301         try:
    302             f = open(fullname, 'r')
    303         except IOError:
    304             return sequences
    305         while 1:
    306             line = f.readline()
    307             if not line: break
    308             fields = line.split(':')
    309             if len(fields) != 2:
    310                 self.error('bad sequence in %s: %s' %
    311                           (fullname, line.strip()))
    312             key = fields[0].strip()
    313             value = IntSet(fields[1].strip(), ' ').tolist()
    314             sequences[key] = value
    315         return sequences
    316 
    317     def putsequences(self, sequences):
    318         """Write the set of sequences back to the folder."""
    319         fullname = self.getsequencesfilename()
    320         f = None
    321         for key, seq in sequences.iteritems():
    322             s = IntSet('', ' ')
    323             s.fromlist(seq)
    324             if not f: f = open(fullname, 'w')
    325             f.write('%s: %s\n' % (key, s.tostring()))
    326         if not f:
    327             try:
    328                 os.unlink(fullname)
    329             except os.error:
    330                 pass
    331         else:
    332             f.close()
    333 
    334     def getcurrent(self):
    335         """Return the current message.  Raise Error when there is none."""
    336         seqs = self.getsequences()
    337         try:
    338             return max(seqs['cur'])
    339         except (ValueError, KeyError):
    340             raise Error, "no cur message"
    341 
    342     def setcurrent(self, n):
    343         """Set the current message."""
    344         updateline(self.getsequencesfilename(), 'cur', str(n), 0)
    345 
    346     def parsesequence(self, seq):
    347         """Parse an MH sequence specification into a message list.
    348         Attempt to mimic mh-sequence(5) as close as possible.
    349         Also attempt to mimic observed behavior regarding which
    350         conditions cause which error messages."""
    351         # XXX Still not complete (see mh-format(5)).
    352         # Missing are:
    353         # - 'prev', 'next' as count
    354         # - Sequence-Negation option
    355         all = self.listmessages()
    356         # Observed behavior: test for empty folder is done first
    357         if not all:
    358             raise Error, "no messages in %s" % self.name
    359         # Common case first: all is frequently the default
    360         if seq == 'all':
    361             return all
    362         # Test for X:Y before X-Y because 'seq:-n' matches both
    363         i = seq.find(':')
    364         if i >= 0:
    365             head, dir, tail = seq[:i], '', seq[i+1:]
    366             if tail[:1] in '-+':
    367                 dir, tail = tail[:1], tail[1:]
    368             if not isnumeric(tail):
    369                 raise Error, "bad message list %s" % seq
    370             try:
    371                 count = int(tail)
    372             except (ValueError, OverflowError):
    373                 # Can't use sys.maxint because of i+count below
    374                 count = len(all)
    375             try:
    376                 anchor = self._parseindex(head, all)
    377             except Error, msg:
    378                 seqs = self.getsequences()
    379                 if not head in seqs:
    380                     if not msg:
    381                         msg = "bad message list %s" % seq
    382                     raise Error, msg, sys.exc_info()[2]
    383                 msgs = seqs[head]
    384                 if not msgs:
    385                     raise Error, "sequence %s empty" % head
    386                 if dir == '-':
    387                     return msgs[-count:]
    388                 else:
    389                     return msgs[:count]
    390             else:
    391                 if not dir:
    392                     if head in ('prev', 'last'):
    393                         dir = '-'
    394                 if dir == '-':
    395                     i = bisect(all, anchor)
    396                     return all[max(0, i-count):i]
    397                 else:
    398                     i = bisect(all, anchor-1)
    399                     return all[i:i+count]
    400         # Test for X-Y next
    401         i = seq.find('-')
    402         if i >= 0:
    403             begin = self._parseindex(seq[:i], all)
    404             end = self._parseindex(seq[i+1:], all)
    405             i = bisect(all, begin-1)
    406             j = bisect(all, end)
    407             r = all[i:j]
    408             if not r:
    409                 raise Error, "bad message list %s" % seq
    410             return r
    411         # Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
    412         try:
    413             n = self._parseindex(seq, all)
    414         except Error, msg:
    415             seqs = self.getsequences()
    416             if not seq in seqs:
    417                 if not msg:
    418                     msg = "bad message list %s" % seq
    419                 raise Error, msg
    420             return seqs[seq]
    421         else:
    422             if n not in all:
    423                 if isnumeric(seq):
    424                     raise Error, "message %d doesn't exist" % n
    425                 else:
    426                     raise Error, "no %s message" % seq
    427             else:
    428                 return [n]
    429 
    430     def _parseindex(self, seq, all):
    431         """Internal: parse a message number (or cur, first, etc.)."""
    432         if isnumeric(seq):
    433             try:
    434                 return int(seq)
    435             except (OverflowError, ValueError):
    436                 return sys.maxint
    437         if seq in ('cur', '.'):
    438             return self.getcurrent()
    439         if seq == 'first':
    440             return all[0]
    441         if seq == 'last':
    442             return all[-1]
    443         if seq == 'next':
    444             n = self.getcurrent()
    445             i = bisect(all, n)
    446             try:
    447                 return all[i]
    448             except IndexError:
    449                 raise Error, "no next message"
    450         if seq == 'prev':
    451             n = self.getcurrent()
    452             i = bisect(all, n-1)
    453             if i == 0:
    454                 raise Error, "no prev message"
    455             try:
    456                 return all[i-1]
    457             except IndexError:
    458                 raise Error, "no prev message"
    459         raise Error, None
    460 
    461     def openmessage(self, n):
    462         """Open a message -- returns a Message object."""
    463         return Message(self, n)
    464 
    465     def removemessages(self, list):
    466         """Remove one or more messages -- may raise os.error."""
    467         errors = []
    468         deleted = []
    469         for n in list:
    470             path = self.getmessagefilename(n)
    471             commapath = self.getmessagefilename(',' + str(n))
    472             try:
    473                 os.unlink(commapath)
    474             except os.error:
    475                 pass
    476             try:
    477                 os.rename(path, commapath)
    478             except os.error, msg:
    479                 errors.append(msg)
    480             else:
    481                 deleted.append(n)
    482         if deleted:
    483             self.removefromallsequences(deleted)
    484         if errors:
    485             if len(errors) == 1:
    486                 raise os.error, errors[0]
    487             else:
    488                 raise os.error, ('multiple errors:', errors)
    489 
    490     def refilemessages(self, list, tofolder, keepsequences=0):
    491         """Refile one or more messages -- may raise os.error.
    492         'tofolder' is an open folder object."""
    493         errors = []
    494         refiled = {}
    495         for n in list:
    496             ton = tofolder.getlast() + 1
    497             path = self.getmessagefilename(n)
    498             topath = tofolder.getmessagefilename(ton)
    499             try:
    500                 os.rename(path, topath)
    501             except os.error:
    502                 # Try copying
    503                 try:
    504                     shutil.copy2(path, topath)
    505                     os.unlink(path)
    506                 except (IOError, os.error), msg:
    507                     errors.append(msg)
    508                     try:
    509                         os.unlink(topath)
    510                     except os.error:
    511                         pass
    512                     continue
    513             tofolder.setlast(ton)
    514             refiled[n] = ton
    515         if refiled:
    516             if keepsequences:
    517                 tofolder._copysequences(self, refiled.items())
    518             self.removefromallsequences(refiled.keys())
    519         if errors:
    520             if len(errors) == 1:
    521                 raise os.error, errors[0]
    522             else:
    523                 raise os.error, ('multiple errors:', errors)
    524 
    525     def _copysequences(self, fromfolder, refileditems):
    526         """Helper for refilemessages() to copy sequences."""
    527         fromsequences = fromfolder.getsequences()
    528         tosequences = self.getsequences()
    529         changed = 0
    530         for name, seq in fromsequences.items():
    531             try:
    532                 toseq = tosequences[name]
    533                 new = 0
    534             except KeyError:
    535                 toseq = []
    536                 new = 1
    537             for fromn, ton in refileditems:
    538                 if fromn in seq:
    539                     toseq.append(ton)
    540                     changed = 1
    541             if new and toseq:
    542                 tosequences[name] = toseq
    543         if changed:
    544             self.putsequences(tosequences)
    545 
    546     def movemessage(self, n, tofolder, ton):
    547         """Move one message over a specific destination message,
    548         which may or may not already exist."""
    549         path = self.getmessagefilename(n)
    550         # Open it to check that it exists
    551         f = open(path)
    552         f.close()
    553         del f
    554         topath = tofolder.getmessagefilename(ton)
    555         backuptopath = tofolder.getmessagefilename(',%d' % ton)
    556         try:
    557             os.rename(topath, backuptopath)
    558         except os.error:
    559             pass
    560         try:
    561             os.rename(path, topath)
    562         except os.error:
    563             # Try copying
    564             ok = 0
    565             try:
    566                 tofolder.setlast(None)
    567                 shutil.copy2(path, topath)
    568                 ok = 1
    569             finally:
    570                 if not ok:
    571                     try:
    572                         os.unlink(topath)
    573                     except os.error:
    574                         pass
    575             os.unlink(path)
    576         self.removefromallsequences([n])
    577 
    578     def copymessage(self, n, tofolder, ton):
    579         """Copy one message over a specific destination message,
    580         which may or may not already exist."""
    581         path = self.getmessagefilename(n)
    582         # Open it to check that it exists
    583         f = open(path)
    584         f.close()
    585         del f
    586         topath = tofolder.getmessagefilename(ton)
    587         backuptopath = tofolder.getmessagefilename(',%d' % ton)
    588         try:
    589             os.rename(topath, backuptopath)
    590         except os.error:
    591             pass
    592         ok = 0
    593         try:
    594             tofolder.setlast(None)
    595             shutil.copy2(path, topath)
    596             ok = 1
    597         finally:
    598             if not ok:
    599                 try:
    600                     os.unlink(topath)
    601                 except os.error:
    602                     pass
    603 
    604     def createmessage(self, n, txt):
    605         """Create a message, with text from the open file txt."""
    606         path = self.getmessagefilename(n)
    607         backuppath = self.getmessagefilename(',%d' % n)
    608         try:
    609             os.rename(path, backuppath)
    610         except os.error:
    611             pass
    612         ok = 0
    613         BUFSIZE = 16*1024
    614         try:
    615             f = open(path, "w")
    616             while 1:
    617                 buf = txt.read(BUFSIZE)
    618                 if not buf:
    619                     break
    620                 f.write(buf)
    621             f.close()
    622             ok = 1
    623         finally:
    624             if not ok:
    625                 try:
    626                     os.unlink(path)
    627                 except os.error:
    628                     pass
    629 
    630     def removefromallsequences(self, list):
    631         """Remove one or more messages from all sequences (including last)
    632         -- but not from 'cur'!!!"""
    633         if hasattr(self, 'last') and self.last in list:
    634             del self.last
    635         sequences = self.getsequences()
    636         changed = 0
    637         for name, seq in sequences.items():
    638             if name == 'cur':
    639                 continue
    640             for n in list:
    641                 if n in seq:
    642                     seq.remove(n)
    643                     changed = 1
    644                     if not seq:
    645                         del sequences[name]
    646         if changed:
    647             self.putsequences(sequences)
    648 
    649     def getlast(self):
    650         """Return the last message number."""
    651         if not hasattr(self, 'last'):
    652             self.listmessages() # Set self.last
    653         return self.last
    654 
    655     def setlast(self, last):
    656         """Set the last message number."""
    657         if last is None:
    658             if hasattr(self, 'last'):
    659                 del self.last
    660         else:
    661             self.last = last
    662 
    663 class Message(mimetools.Message):
    664 
    665     def __init__(self, f, n, fp = None):
    666         """Constructor."""
    667         self.folder = f
    668         self.number = n
    669         if fp is None:
    670             path = f.getmessagefilename(n)
    671             fp = open(path, 'r')
    672         mimetools.Message.__init__(self, fp)
    673 
    674     def __repr__(self):
    675         """String representation."""
    676         return 'Message(%s, %s)' % (repr(self.folder), self.number)
    677 
    678     def getheadertext(self, pred = None):
    679         """Return the message's header text as a string.  If an
    680         argument is specified, it is used as a filter predicate to
    681         decide which headers to return (its argument is the header
    682         name converted to lower case)."""
    683         if pred is None:
    684             return ''.join(self.headers)
    685         headers = []
    686         hit = 0
    687         for line in self.headers:
    688             if not line[0].isspace():
    689                 i = line.find(':')
    690                 if i > 0:
    691                     hit = pred(line[:i].lower())
    692             if hit: headers.append(line)
    693         return ''.join(headers)
    694 
    695     def getbodytext(self, decode = 1):
    696         """Return the message's body text as string.  This undoes a
    697         Content-Transfer-Encoding, but does not interpret other MIME
    698         features (e.g. multipart messages).  To suppress decoding,
    699         pass 0 as an argument."""
    700         self.fp.seek(self.startofbody)
    701         encoding = self.getencoding()
    702         if not decode or encoding in ('', '7bit', '8bit', 'binary'):
    703             return self.fp.read()
    704         try:
    705             from cStringIO import StringIO
    706         except ImportError:
    707             from StringIO import StringIO
    708         output = StringIO()
    709         mimetools.decode(self.fp, output, encoding)
    710         return output.getvalue()
    711 
    712     def getbodyparts(self):
    713         """Only for multipart messages: return the message's body as a
    714         list of SubMessage objects.  Each submessage object behaves
    715         (almost) as a Message object."""
    716         if self.getmaintype() != 'multipart':
    717             raise Error, 'Content-Type is not multipart/*'
    718         bdry = self.getparam('boundary')
    719         if not bdry:
    720             raise Error, 'multipart/* without boundary param'
    721         self.fp.seek(self.startofbody)
    722         mf = multifile.MultiFile(self.fp)
    723         mf.push(bdry)
    724         parts = []
    725         while mf.next():
    726             n = "%s.%r" % (self.number, 1 + len(parts))
    727             part = SubMessage(self.folder, n, mf)
    728             parts.append(part)
    729         mf.pop()
    730         return parts
    731 
    732     def getbody(self):
    733         """Return body, either a string or a list of messages."""
    734         if self.getmaintype() == 'multipart':
    735             return self.getbodyparts()
    736         else:
    737             return self.getbodytext()
    738 
    739 
    740 class SubMessage(Message):
    741 
    742     def __init__(self, f, n, fp):
    743         """Constructor."""
    744         Message.__init__(self, f, n, fp)
    745         if self.getmaintype() == 'multipart':
    746             self.body = Message.getbodyparts(self)
    747         else:
    748             self.body = Message.getbodytext(self)
    749         self.bodyencoded = Message.getbodytext(self, decode=0)
    750             # XXX If this is big, should remember file pointers
    751 
    752     def __repr__(self):
    753         """String representation."""
    754         f, n, fp = self.folder, self.number, self.fp
    755         return 'SubMessage(%s, %s, %s)' % (f, n, fp)
    756 
    757     def getbodytext(self, decode = 1):
    758         if not decode:
    759             return self.bodyencoded
    760         if type(self.body) == type(''):
    761             return self.body
    762 
    763     def getbodyparts(self):
    764         if type(self.body) == type([]):
    765             return self.body
    766 
    767     def getbody(self):
    768         return self.body
    769 
    770 
    771 class IntSet:
    772     """Class implementing sets of integers.
    773 
    774     This is an efficient representation for sets consisting of several
    775     continuous ranges, e.g. 1-100,200-400,402-1000 is represented
    776     internally as a list of three pairs: [(1,100), (200,400),
    777     (402,1000)].  The internal representation is always kept normalized.
    778 
    779     The constructor has up to three arguments:
    780     - the string used to initialize the set (default ''),
    781     - the separator between ranges (default ',')
    782     - the separator between begin and end of a range (default '-')
    783     The separators must be strings (not regexprs) and should be different.
    784 
    785     The tostring() function yields a string that can be passed to another
    786     IntSet constructor; __repr__() is a valid IntSet constructor itself.
    787     """
    788 
    789     # XXX The default begin/end separator means that negative numbers are
    790     #     not supported very well.
    791     #
    792     # XXX There are currently no operations to remove set elements.
    793 
    794     def __init__(self, data = None, sep = ',', rng = '-'):
    795         self.pairs = []
    796         self.sep = sep
    797         self.rng = rng
    798         if data: self.fromstring(data)
    799 
    800     def reset(self):
    801         self.pairs = []
    802 
    803     def __cmp__(self, other):
    804         return cmp(self.pairs, other.pairs)
    805 
    806     def __hash__(self):
    807         return hash(self.pairs)
    808 
    809     def __repr__(self):
    810         return 'IntSet(%r, %r, %r)' % (self.tostring(), self.sep, self.rng)
    811 
    812     def normalize(self):
    813         self.pairs.sort()
    814         i = 1
    815         while i < len(self.pairs):
    816             alo, ahi = self.pairs[i-1]
    817             blo, bhi = self.pairs[i]
    818             if ahi >= blo-1:
    819                 self.pairs[i-1:i+1] = [(alo, max(ahi, bhi))]
    820             else:
    821                 i = i+1
    822 
    823     def tostring(self):
    824         s = ''
    825         for lo, hi in self.pairs:
    826             if lo == hi: t = repr(lo)
    827             else: t = repr(lo) + self.rng + repr(hi)
    828             if s: s = s + (self.sep + t)
    829             else: s = t
    830         return s
    831 
    832     def tolist(self):
    833         l = []
    834         for lo, hi in self.pairs:
    835             m = range(lo, hi+1)
    836             l = l + m
    837         return l
    838 
    839     def fromlist(self, list):
    840         for i in list:
    841             self.append(i)
    842 
    843     def clone(self):
    844         new = IntSet()
    845         new.pairs = self.pairs[:]
    846         return new
    847 
    848     def min(self):
    849         return self.pairs[0][0]
    850 
    851     def max(self):
    852         return self.pairs[-1][-1]
    853 
    854     def contains(self, x):
    855         for lo, hi in self.pairs:
    856             if lo <= x <= hi: return True
    857         return False
    858 
    859     def append(self, x):
    860         for i in range(len(self.pairs)):
    861             lo, hi = self.pairs[i]
    862             if x < lo: # Need to insert before
    863                 if x+1 == lo:
    864                     self.pairs[i] = (x, hi)
    865                 else:
    866                     self.pairs.insert(i, (x, x))
    867                 if i > 0 and x-1 == self.pairs[i-1][1]:
    868                     # Merge with previous
    869                     self.pairs[i-1:i+1] = [
    870                             (self.pairs[i-1][0],
    871                              self.pairs[i][1])
    872                           ]
    873                 return
    874             if x <= hi: # Already in set
    875                 return
    876         i = len(self.pairs) - 1
    877         if i >= 0:
    878             lo, hi = self.pairs[i]
    879             if x-1 == hi:
    880                 self.pairs[i] = lo, x
    881                 return
    882         self.pairs.append((x, x))
    883 
    884     def addpair(self, xlo, xhi):
    885         if xlo > xhi: return
    886         self.pairs.append((xlo, xhi))
    887         self.normalize()
    888 
    889     def fromstring(self, data):
    890         new = []
    891         for part in data.split(self.sep):
    892             list = []
    893             for subp in part.split(self.rng):
    894                 s = subp.strip()
    895                 list.append(int(s))
    896             if len(list) == 1:
    897                 new.append((list[0], list[0]))
    898             elif len(list) == 2 and list[0] <= list[1]:
    899                 new.append((list[0], list[1]))
    900             else:
    901                 raise ValueError, 'bad data passed to IntSet'
    902         self.pairs = self.pairs + new
    903         self.normalize()
    904 
    905 
    906 # Subroutines to read/write entries in .mh_profile and .mh_sequences
    907 
    908 def pickline(file, key, casefold = 1):
    909     try:
    910         f = open(file, 'r')
    911     except IOError:
    912         return None
    913     pat = re.escape(key) + ':'
    914     prog = re.compile(pat, casefold and re.IGNORECASE)
    915     while 1:
    916         line = f.readline()
    917         if not line: break
    918         if prog.match(line):
    919             text = line[len(key)+1:]
    920             while 1:
    921                 line = f.readline()
    922                 if not line or not line[0].isspace():
    923                     break
    924                 text = text + line
    925             return text.strip()
    926     return None
    927 
    928 def updateline(file, key, value, casefold = 1):
    929     try:
    930         f = open(file, 'r')
    931         lines = f.readlines()
    932         f.close()
    933     except IOError:
    934         lines = []
    935     pat = re.escape(key) + ':(.*)\n'
    936     prog = re.compile(pat, casefold and re.IGNORECASE)
    937     if value is None:
    938         newline = None
    939     else:
    940         newline = '%s: %s\n' % (key, value)
    941     for i in range(len(lines)):
    942         line = lines[i]
    943         if prog.match(line):
    944             if newline is None:
    945                 del lines[i]
    946             else:
    947                 lines[i] = newline
    948             break
    949     else:
    950         if newline is not None:
    951             lines.append(newline)
    952     tempfile = file + "~"
    953     f = open(tempfile, 'w')
    954     for line in lines:
    955         f.write(line)
    956     f.close()
    957     os.rename(tempfile, file)
    958 
    959 
    960 # Test program
    961 
    962 def test():
    963     global mh, f
    964     os.system('rm -rf $HOME/Mail/@test')
    965     mh = MH()
    966     def do(s): print s; print eval(s)
    967     do('mh.listfolders()')
    968     do('mh.listallfolders()')
    969     testfolders = ['@test', '@test/test1', '@test/test2',
    970                    '@test/test1/test11', '@test/test1/test12',
    971                    '@test/test1/test11/test111']
    972     for t in testfolders: do('mh.makefolder(%r)' % (t,))
    973     do('mh.listsubfolders(\'@test\')')
    974     do('mh.listallsubfolders(\'@test\')')
    975     f = mh.openfolder('@test')
    976     do('f.listsubfolders()')
    977     do('f.listallsubfolders()')
    978     do('f.getsequences()')
    979     seqs = f.getsequences()
    980     seqs['foo'] = IntSet('1-10 12-20', ' ').tolist()
    981     print seqs
    982     f.putsequences(seqs)
    983     do('f.getsequences()')
    984     for t in reversed(testfolders): do('mh.deletefolder(%r)' % (t,))
    985     do('mh.getcontext()')
    986     context = mh.getcontext()
    987     f = mh.openfolder(context)
    988     do('f.getcurrent()')
    989     for seq in ('first', 'last', 'cur', '.', 'prev', 'next',
    990                 'first:3', 'last:3', 'cur:3', 'cur:-3',
    991                 'prev:3', 'next:3',
    992                 '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
    993                 'all'):
    994         try:
    995             do('f.parsesequence(%r)' % (seq,))
    996         except Error, msg:
    997             print "Error:", msg
    998         stuff = os.popen("pick %r 2>/dev/null" % (seq,)).read()
    999         list = map(int, stuff.split())
   1000         print list, "<-- pick"
   1001     do('f.listmessages()')
   1002 
   1003 
   1004 if __name__ == '__main__':
   1005     test()
   1006