1"""MH interface -- purely object-oriented (well, almost)
2
3Executive summary:
4
5import mhlib
6
7mh = mhlib.MH()         # use default mailbox directory and profile
8mh = mhlib.MH(mailbox)  # override mailbox location (default from profile)
9mh = mhlib.MH(mailbox, profile) # override mailbox and profile
10
11mh.error(format, ...)   # print error message -- can be overridden
12s = mh.getprofile(key)  # profile entry (None if not set)
13path = mh.getpath()     # mailbox pathname
14name = mh.getcontext()  # name of current folder
15mh.setcontext(name)     # set name of current folder
16
17list = mh.listfolders() # names of top-level folders
18list = mh.listallfolders() # names of all folders, including subfolders
19list = mh.listsubfolders(name) # direct subfolders of given folder
20list = mh.listallsubfolders(name) # all subfolders of given folder
21
22mh.makefolder(name)     # create new folder
23mh.deletefolder(name)   # delete folder -- must have no subfolders
24
25f = mh.openfolder(name) # new open folder object
26
27f.error(format, ...)    # same as mh.error(format, ...)
28path = f.getfullname()  # folder's full pathname
29path = f.getsequencesfilename() # full pathname of folder's sequences file
30path = f.getmessagefilename(n)  # full pathname of message n in folder
31
32list = f.listmessages() # list of messages in folder (as numbers)
33n = f.getcurrent()      # get current message
34f.setcurrent(n)         # set current message
35list = f.parsesequence(seq)     # parse msgs syntax into list of messages
36n = f.getlast()         # get last message (0 if no messagse)
37f.setlast(n)            # set last message (internal use only)
38
39dict = f.getsequences() # dictionary of sequences in folder {name: list}
40f.putsequences(dict)    # write sequences back to folder
41
42f.createmessage(n, fp)  # add message from file f as number n
43f.removemessages(list)  # remove messages in list from folder
44f.refilemessages(list, tofolder) # move messages in list to other folder
45f.movemessage(n, tofolder, ton)  # move one message to a given destination
46f.copymessage(n, tofolder, ton)  # copy one message to a given destination
47
48m = f.openmessage(n)    # new open message object (costs a file descriptor)
49m is a derived class of mimetools.Message(rfc822.Message), with:
50s = m.getheadertext()   # text of message's headers
51s = m.getheadertext(pred) # text of message's headers, filtered by pred
52s = m.getbodytext()     # text of message's body, decoded
53s = m.getbodytext(0)    # text of message's body, not decoded
54"""
55from warnings import warnpy3k
56warnpy3k("the mhlib module has been removed in Python 3.0; use the mailbox "
57            "module instead", stacklevel=2)
58del 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
71MH_PROFILE = '~/.mh_profile'
72PATH = '~/Mail'
73MH_SEQUENCES = '.mh_sequences'
74FOLDER_PROTECT = 0700
75
76
77# Imported modules
78
79import os
80import sys
81import re
82import mimetools
83import multifile
84import shutil
85from bisect import bisect
86
87__all__ = ["MH","Error","Folder","Message"]
88
89# Exported constants
90
91class Error(Exception):
92    pass
93
94
95class 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
238numericprog = re.compile('^[1-9][0-9]*$')
239def isnumeric(str):
240    return numericprog.match(str) is not None
241
242class 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
663class 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
740class 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
771class 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
908def 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
928def 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
962def 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
1004if __name__ == '__main__':
1005    test()
1006