mhlib.py 32.5 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
"""MH interface -- purely object-oriented (well, almost)

Executive summary:

import mhlib

mh = mhlib.MH()         # use default mailbox directory and profile
mh = mhlib.MH(mailbox)  # override mailbox location (default from profile)
mh = mhlib.MH(mailbox, profile) # override mailbox and profile

mh.error(format, ...)   # print error message -- can be overridden
s = mh.getprofile(key)  # profile entry (None if not set)
path = mh.getpath()     # mailbox pathname
name = mh.getcontext()  # name of current folder
mh.setcontext(name)     # set name of current folder

list = mh.listfolders() # names of top-level folders
list = mh.listallfolders() # names of all folders, including subfolders
list = mh.listsubfolders(name) # direct subfolders of given folder
list = mh.listallsubfolders(name) # all subfolders of given folder

mh.makefolder(name)     # create new folder
mh.deletefolder(name)   # delete folder -- must have no subfolders

f = mh.openfolder(name) # new open folder object

f.error(format, ...)    # same as mh.error(format, ...)
path = f.getfullname()  # folder's full pathname
path = f.getsequencesfilename() # full pathname of folder's sequences file
path = f.getmessagefilename(n)  # full pathname of message n in folder

list = f.listmessages() # list of messages in folder (as numbers)
n = f.getcurrent()      # get current message
f.setcurrent(n)         # set current message
list = f.parsesequence(seq)     # parse msgs syntax into list of messages
n = f.getlast()         # get last message (0 if no messagse)
f.setlast(n)            # set last message (internal use only)

dict = f.getsequences() # dictionary of sequences in folder {name: list}
f.putsequences(dict)    # write sequences back to folder

f.createmessage(n, fp)  # add message from file f as number n
f.removemessages(list)  # remove messages in list from folder
f.refilemessages(list, tofolder) # move messages in list to other folder
f.movemessage(n, tofolder, ton)  # move one message to a given destination
f.copymessage(n, tofolder, ton)  # copy one message to a given destination

m = f.openmessage(n)    # new open message object (costs a file descriptor)
m is a derived class of mimetools.Message(rfc822.Message), with:
s = m.getheadertext()   # text of message's headers
s = m.getheadertext(pred) # text of message's headers, filtered by pred
s = m.getbodytext()     # text of message's body, decoded
s = m.getbodytext(0)    # text of message's body, not decoded
"""

56 57
# XXX To do, functionality:
# - annotate messages
58
# - send messages
59
#
60
# XXX To do, organization:
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
# - move IntSet to separate file
# - move most Message functionality to module mimetools


# Customizable defaults

MH_PROFILE = '~/.mh_profile'
PATH = '~/Mail'
MH_SEQUENCES = '.mh_sequences'
FOLDER_PROTECT = 0700


# Imported modules

import os
76
import sys
77
import re
78 79
import mimetools
import multifile
80
import shutil
81
from bisect import bisect
82

83
__all__ = ["MH","Error","Folder","Message"]
84 85 86

# Exported constants

87 88
class Error(Exception):
    pass
89 90 91


class MH:
92 93 94 95 96
    """Class representing a particular collection of folders.
    Optional constructor arguments are the pathname for the directory
    containing the collection, and the MH profile to use.
    If either is omitted or empty a default is used; the default
    directory is taken from the MH profile if it is specified there."""
97

98
    def __init__(self, path = None, profile = None):
99
        """Constructor."""
100
        if profile is None: profile = MH_PROFILE
101
        self.profile = os.path.expanduser(profile)
102
        if path is None: path = self.getprofile('Path')
103 104 105 106 107 108
        if not path: path = PATH
        if not os.path.isabs(path) and path[0] != '~':
            path = os.path.join('~', path)
        path = os.path.expanduser(path)
        if not os.path.isdir(path): raise Error, 'MH() path not found'
        self.path = path
109 110

    def __repr__(self):
111
        """String representation."""
112
        return 'MH(%r, %r)' % (self.path, self.profile)
113 114

    def error(self, msg, *args):
115
        """Routine to print an error.  May be overridden by a derived class."""
116
        sys.stderr.write('MH error: %s\n' % (msg % args))
117 118

    def getprofile(self, key):
119
        """Return a profile entry, None if not found."""
120
        return pickline(self.profile, key)
121 122

    def getpath(self):
123
        """Return the path (the name of the collection's directory)."""
124
        return self.path
125 126

    def getcontext(self):
127
        """Return the name of the current folder."""
128 129 130 131
        context = pickline(os.path.join(self.getpath(), 'context'),
                  'Current-Folder')
        if not context: context = 'inbox'
        return context
132 133

    def setcontext(self, context):
134
        """Set the name of the current folder."""
135 136 137 138
        fn = os.path.join(self.getpath(), 'context')
        f = open(fn, "w")
        f.write("Current-Folder: %s\n" % context)
        f.close()
139 140

    def listfolders(self):
141
        """Return the names of the top-level folders."""
142 143 144 145 146 147 148 149
        folders = []
        path = self.getpath()
        for name in os.listdir(path):
            fullname = os.path.join(path, name)
            if os.path.isdir(fullname):
                folders.append(name)
        folders.sort()
        return folders
150 151

    def listsubfolders(self, name):
152 153
        """Return the names of the subfolders in a given folder
        (prefixed with the given folder name)."""
154 155 156
        fullname = os.path.join(self.path, name)
        # Get the link count so we can avoid listing folders
        # that have no subfolders.
157
        nlinks = os.stat(fullname).st_nlink
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
        if nlinks <= 2:
            return []
        subfolders = []
        subnames = os.listdir(fullname)
        for subname in subnames:
            fullsubname = os.path.join(fullname, subname)
            if os.path.isdir(fullsubname):
                name_subname = os.path.join(name, subname)
                subfolders.append(name_subname)
                # Stop looking for subfolders when
                # we've seen them all
                nlinks = nlinks - 1
                if nlinks <= 2:
                    break
        subfolders.sort()
        return subfolders
174 175

    def listallfolders(self):
176
        """Return the names of all folders and subfolders, recursively."""
177
        return self.listallsubfolders('')
178 179

    def listallsubfolders(self, name):
180
        """Return the names of subfolders in a given folder, recursively."""
181 182 183
        fullname = os.path.join(self.path, name)
        # Get the link count so we can avoid listing folders
        # that have no subfolders.
184
        nlinks = os.stat(fullname).st_nlink
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205
        if nlinks <= 2:
            return []
        subfolders = []
        subnames = os.listdir(fullname)
        for subname in subnames:
            if subname[0] == ',' or isnumeric(subname): continue
            fullsubname = os.path.join(fullname, subname)
            if os.path.isdir(fullsubname):
                name_subname = os.path.join(name, subname)
                subfolders.append(name_subname)
                if not os.path.islink(fullsubname):
                    subsubfolders = self.listallsubfolders(
                              name_subname)
                    subfolders = subfolders + subsubfolders
                # Stop looking for subfolders when
                # we've seen them all
                nlinks = nlinks - 1
                if nlinks <= 2:
                    break
        subfolders.sort()
        return subfolders
206 207

    def openfolder(self, name):
208
        """Return a new Folder object for the named folder."""
209
        return Folder(self, name)
210 211

    def makefolder(self, name):
212
        """Create a new folder (or raise os.error if it cannot be created)."""
213 214
        protect = pickline(self.profile, 'Folder-Protect')
        if protect and isnumeric(protect):
215
            mode = int(protect, 8)
216 217 218
        else:
            mode = FOLDER_PROTECT
        os.mkdir(os.path.join(self.getpath(), name), mode)
219 220

    def deletefolder(self, name):
221 222
        """Delete a folder.  This removes files in the folder but not
        subdirectories.  Raise os.error if deleting the folder itself fails."""
223 224 225 226 227 228 229 230 231
        fullname = os.path.join(self.getpath(), name)
        for subname in os.listdir(fullname):
            fullsubname = os.path.join(fullname, subname)
            try:
                os.unlink(fullsubname)
            except os.error:
                self.error('%s not deleted, continuing...' %
                          fullsubname)
        os.rmdir(fullname)
232 233


234
numericprog = re.compile('^[1-9][0-9]*$')
235
def isnumeric(str):
236
    return numericprog.match(str) is not None
237 238

class Folder:
239
    """Class representing a particular folder."""
240

241
    def __init__(self, mh, name):
242
        """Constructor."""
243 244 245 246
        self.mh = mh
        self.name = name
        if not os.path.isdir(self.getfullname()):
            raise Error, 'no folder %s' % name
247 248

    def __repr__(self):
249
        """String representation."""
250
        return 'Folder(%r, %r)' % (self.mh, self.name)
251 252

    def error(self, *args):
253
        """Error message handler."""
254
        self.mh.error(*args)
255 256

    def getfullname(self):
257
        """Return the full pathname of the folder."""
258
        return os.path.join(self.mh.path, self.name)
259 260

    def getsequencesfilename(self):
261
        """Return the full pathname of the folder's sequences file."""
262
        return os.path.join(self.getfullname(), MH_SEQUENCES)
263 264

    def getmessagefilename(self, n):
265
        """Return the full pathname of a message in the folder."""
266
        return os.path.join(self.getfullname(), str(n))
267 268

    def listsubfolders(self):
269
        """Return list of direct subfolders."""
270
        return self.mh.listsubfolders(self.name)
271 272

    def listallsubfolders(self):
273
        """Return list of all subfolders."""
274
        return self.mh.listallsubfolders(self.name)
275 276

    def listmessages(self):
277 278
        """Return the list of messages currently present in the folder.
        As a side effect, set self.last to the last message (or 0)."""
279 280 281 282
        messages = []
        match = numericprog.match
        append = messages.append
        for name in os.listdir(self.getfullname()):
283
            if match(name):
284
                append(name)
285
        messages = map(int, messages)
286 287 288 289 290 291
        messages.sort()
        if messages:
            self.last = messages[-1]
        else:
            self.last = 0
        return messages
292

293
    def getsequences(self):
294
        """Return the set of sequences for the folder."""
295 296 297 298 299 300 301 302 303
        sequences = {}
        fullname = self.getsequencesfilename()
        try:
            f = open(fullname, 'r')
        except IOError:
            return sequences
        while 1:
            line = f.readline()
            if not line: break
304
            fields = line.split(':')
305
            if len(fields) != 2:
306
                self.error('bad sequence in %s: %s' %
307 308 309
                          (fullname, line.strip()))
            key = fields[0].strip()
            value = IntSet(fields[1].strip(), ' ').tolist()
310 311
            sequences[key] = value
        return sequences
312 313

    def putsequences(self, sequences):
314
        """Write the set of sequences back to the folder."""
315 316
        fullname = self.getsequencesfilename()
        f = None
317
        for key, seq in sequences.iteritems():
318
            s = IntSet('', ' ')
319
            s.fromlist(seq)
320 321 322 323 324 325 326 327 328
            if not f: f = open(fullname, 'w')
            f.write('%s: %s\n' % (key, s.tostring()))
        if not f:
            try:
                os.unlink(fullname)
            except os.error:
                pass
        else:
            f.close()
329

330
    def getcurrent(self):
331
        """Return the current message.  Raise Error when there is none."""
332 333 334 335 336
        seqs = self.getsequences()
        try:
            return max(seqs['cur'])
        except (ValueError, KeyError):
            raise Error, "no cur message"
337 338

    def setcurrent(self, n):
339
        """Set the current message."""
340
        updateline(self.getsequencesfilename(), 'cur', str(n), 0)
341 342

    def parsesequence(self, seq):
343 344 345 346
        """Parse an MH sequence specification into a message list.
        Attempt to mimic mh-sequence(5) as close as possible.
        Also attempt to mimic observed behavior regarding which
        conditions cause which error messages."""
347 348 349 350 351 352 353 354 355 356 357 358
        # XXX Still not complete (see mh-format(5)).
        # Missing are:
        # - 'prev', 'next' as count
        # - Sequence-Negation option
        all = self.listmessages()
        # Observed behavior: test for empty folder is done first
        if not all:
            raise Error, "no messages in %s" % self.name
        # Common case first: all is frequently the default
        if seq == 'all':
            return all
        # Test for X:Y before X-Y because 'seq:-n' matches both
359
        i = seq.find(':')
360 361 362 363 364 365 366
        if i >= 0:
            head, dir, tail = seq[:i], '', seq[i+1:]
            if tail[:1] in '-+':
                dir, tail = tail[:1], tail[1:]
            if not isnumeric(tail):
                raise Error, "bad message list %s" % seq
            try:
367
                count = int(tail)
368 369 370 371 372 373 374
            except (ValueError, OverflowError):
                # Can't use sys.maxint because of i+count below
                count = len(all)
            try:
                anchor = self._parseindex(head, all)
            except Error, msg:
                seqs = self.getsequences()
375
                if not head in seqs:
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396
                    if not msg:
                        msg = "bad message list %s" % seq
                    raise Error, msg, sys.exc_info()[2]
                msgs = seqs[head]
                if not msgs:
                    raise Error, "sequence %s empty" % head
                if dir == '-':
                    return msgs[-count:]
                else:
                    return msgs[:count]
            else:
                if not dir:
                    if head in ('prev', 'last'):
                        dir = '-'
                if dir == '-':
                    i = bisect(all, anchor)
                    return all[max(0, i-count):i]
                else:
                    i = bisect(all, anchor-1)
                    return all[i:i+count]
        # Test for X-Y next
397
        i = seq.find('-')
398 399 400 401 402 403 404 405 406 407 408 409 410 411
        if i >= 0:
            begin = self._parseindex(seq[:i], all)
            end = self._parseindex(seq[i+1:], all)
            i = bisect(all, begin-1)
            j = bisect(all, end)
            r = all[i:j]
            if not r:
                raise Error, "bad message list %s" % seq
            return r
        # Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
        try:
            n = self._parseindex(seq, all)
        except Error, msg:
            seqs = self.getsequences()
412
            if not seq in seqs:
413 414 415 416 417 418 419 420 421 422 423 424
                if not msg:
                    msg = "bad message list %s" % seq
                raise Error, msg
            return seqs[seq]
        else:
            if n not in all:
                if isnumeric(seq):
                    raise Error, "message %d doesn't exist" % n
                else:
                    raise Error, "no %s message" % seq
            else:
                return [n]
425 426

    def _parseindex(self, seq, all):
427
        """Internal: parse a message number (or cur, first, etc.)."""
428 429
        if isnumeric(seq):
            try:
430
                return int(seq)
431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455
            except (OverflowError, ValueError):
                return sys.maxint
        if seq in ('cur', '.'):
            return self.getcurrent()
        if seq == 'first':
            return all[0]
        if seq == 'last':
            return all[-1]
        if seq == 'next':
            n = self.getcurrent()
            i = bisect(all, n)
            try:
                return all[i]
            except IndexError:
                raise Error, "no next message"
        if seq == 'prev':
            n = self.getcurrent()
            i = bisect(all, n-1)
            if i == 0:
                raise Error, "no prev message"
            try:
                return all[i-1]
            except IndexError:
                raise Error, "no prev message"
        raise Error, None
456 457

    def openmessage(self, n):
458
        """Open a message -- returns a Message object."""
459
        return Message(self, n)
460 461

    def removemessages(self, list):
462
        """Remove one or more messages -- may raise os.error."""
463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484
        errors = []
        deleted = []
        for n in list:
            path = self.getmessagefilename(n)
            commapath = self.getmessagefilename(',' + str(n))
            try:
                os.unlink(commapath)
            except os.error:
                pass
            try:
                os.rename(path, commapath)
            except os.error, msg:
                errors.append(msg)
            else:
                deleted.append(n)
        if deleted:
            self.removefromallsequences(deleted)
        if errors:
            if len(errors) == 1:
                raise os.error, errors[0]
            else:
                raise os.error, ('multiple errors:', errors)
485 486

    def refilemessages(self, list, tofolder, keepsequences=0):
487 488
        """Refile one or more messages -- may raise os.error.
        'tofolder' is an open folder object."""
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519
        errors = []
        refiled = {}
        for n in list:
            ton = tofolder.getlast() + 1
            path = self.getmessagefilename(n)
            topath = tofolder.getmessagefilename(ton)
            try:
                os.rename(path, topath)
            except os.error:
                # Try copying
                try:
                    shutil.copy2(path, topath)
                    os.unlink(path)
                except (IOError, os.error), msg:
                    errors.append(msg)
                    try:
                        os.unlink(topath)
                    except os.error:
                        pass
                    continue
            tofolder.setlast(ton)
            refiled[n] = ton
        if refiled:
            if keepsequences:
                tofolder._copysequences(self, refiled.items())
            self.removefromallsequences(refiled.keys())
        if errors:
            if len(errors) == 1:
                raise os.error, errors[0]
            else:
                raise os.error, ('multiple errors:', errors)
520 521

    def _copysequences(self, fromfolder, refileditems):
522
        """Helper for refilemessages() to copy sequences."""
523 524 525 526 527 528 529
        fromsequences = fromfolder.getsequences()
        tosequences = self.getsequences()
        changed = 0
        for name, seq in fromsequences.items():
            try:
                toseq = tosequences[name]
                new = 0
530
            except KeyError:
531 532 533 534 535 536 537 538 539 540
                toseq = []
                new = 1
            for fromn, ton in refileditems:
                if fromn in seq:
                    toseq.append(ton)
                    changed = 1
            if new and toseq:
                tosequences[name] = toseq
        if changed:
            self.putsequences(tosequences)
541 542

    def movemessage(self, n, tofolder, ton):
543 544
        """Move one message over a specific destination message,
        which may or may not already exist."""
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572
        path = self.getmessagefilename(n)
        # Open it to check that it exists
        f = open(path)
        f.close()
        del f
        topath = tofolder.getmessagefilename(ton)
        backuptopath = tofolder.getmessagefilename(',%d' % ton)
        try:
            os.rename(topath, backuptopath)
        except os.error:
            pass
        try:
            os.rename(path, topath)
        except os.error:
            # Try copying
            ok = 0
            try:
                tofolder.setlast(None)
                shutil.copy2(path, topath)
                ok = 1
            finally:
                if not ok:
                    try:
                        os.unlink(topath)
                    except os.error:
                        pass
            os.unlink(path)
        self.removefromallsequences([n])
573 574

    def copymessage(self, n, tofolder, ton):
575 576
        """Copy one message over a specific destination message,
        which may or may not already exist."""
577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598
        path = self.getmessagefilename(n)
        # Open it to check that it exists
        f = open(path)
        f.close()
        del f
        topath = tofolder.getmessagefilename(ton)
        backuptopath = tofolder.getmessagefilename(',%d' % ton)
        try:
            os.rename(topath, backuptopath)
        except os.error:
            pass
        ok = 0
        try:
            tofolder.setlast(None)
            shutil.copy2(path, topath)
            ok = 1
        finally:
            if not ok:
                try:
                    os.unlink(topath)
                except os.error:
                    pass
599

600
    def createmessage(self, n, txt):
601
        """Create a message, with text from the open file txt."""
602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624
        path = self.getmessagefilename(n)
        backuppath = self.getmessagefilename(',%d' % n)
        try:
            os.rename(path, backuppath)
        except os.error:
            pass
        ok = 0
        BUFSIZE = 16*1024
        try:
            f = open(path, "w")
            while 1:
                buf = txt.read(BUFSIZE)
                if not buf:
                    break
                f.write(buf)
            f.close()
            ok = 1
        finally:
            if not ok:
                try:
                    os.unlink(path)
                except os.error:
                    pass
625

626
    def removefromallsequences(self, list):
627
        """Remove one or more messages from all sequences (including last)
628
        -- but not from 'cur'!!!"""
629 630 631 632 633 634 635 636 637 638 639 640 641 642 643
        if hasattr(self, 'last') and self.last in list:
            del self.last
        sequences = self.getsequences()
        changed = 0
        for name, seq in sequences.items():
            if name == 'cur':
                continue
            for n in list:
                if n in seq:
                    seq.remove(n)
                    changed = 1
                    if not seq:
                        del sequences[name]
        if changed:
            self.putsequences(sequences)
644 645

    def getlast(self):
646
        """Return the last message number."""
647
        if not hasattr(self, 'last'):
648
            self.listmessages() # Set self.last
649
        return self.last
650 651

    def setlast(self, last):
652
        """Set the last message number."""
653 654 655 656 657
        if last is None:
            if hasattr(self, 'last'):
                del self.last
        else:
            self.last = last
658 659 660

class Message(mimetools.Message):

661
    def __init__(self, f, n, fp = None):
662
        """Constructor."""
663 664
        self.folder = f
        self.number = n
665
        if fp is None:
666 667 668
            path = f.getmessagefilename(n)
            fp = open(path, 'r')
        mimetools.Message.__init__(self, fp)
669 670

    def __repr__(self):
671
        """String representation."""
672
        return 'Message(%s, %s)' % (repr(self.folder), self.number)
673 674

    def getheadertext(self, pred = None):
675 676 677 678
        """Return the message's header text as a string.  If an
        argument is specified, it is used as a filter predicate to
        decide which headers to return (its argument is the header
        name converted to lower case)."""
679
        if pred is None:
680
            return ''.join(self.headers)
681 682 683
        headers = []
        hit = 0
        for line in self.headers:
Eric S. Raymond's avatar
Eric S. Raymond committed
684
            if not line[0].isspace():
685
                i = line.find(':')
686
                if i > 0:
687
                    hit = pred(line[:i].lower())
688
            if hit: headers.append(line)
689
        return ''.join(headers)
690 691

    def getbodytext(self, decode = 1):
692 693 694 695
        """Return the message's body text as string.  This undoes a
        Content-Transfer-Encoding, but does not interpret other MIME
        features (e.g. multipart messages).  To suppress decoding,
        pass 0 as an argument."""
696 697
        self.fp.seek(self.startofbody)
        encoding = self.getencoding()
698
        if not decode or encoding in ('', '7bit', '8bit', 'binary'):
699
            return self.fp.read()
700 701 702 703
        try:
            from cStringIO import StringIO
        except ImportError:
            from StringIO import StringIO
704 705 706
        output = StringIO()
        mimetools.decode(self.fp, output, encoding)
        return output.getvalue()
707 708

    def getbodyparts(self):
709 710 711
        """Only for multipart messages: return the message's body as a
        list of SubMessage objects.  Each submessage object behaves
        (almost) as a Message object."""
712 713 714 715 716 717 718 719 720 721
        if self.getmaintype() != 'multipart':
            raise Error, 'Content-Type is not multipart/*'
        bdry = self.getparam('boundary')
        if not bdry:
            raise Error, 'multipart/* without boundary param'
        self.fp.seek(self.startofbody)
        mf = multifile.MultiFile(self.fp)
        mf.push(bdry)
        parts = []
        while mf.next():
722
            n = "%s.%r" % (self.number, 1 + len(parts))
723 724 725 726
            part = SubMessage(self.folder, n, mf)
            parts.append(part)
        mf.pop()
        return parts
727 728

    def getbody(self):
729
        """Return body, either a string or a list of messages."""
730 731 732 733
        if self.getmaintype() == 'multipart':
            return self.getbodyparts()
        else:
            return self.getbodytext()
734 735 736 737


class SubMessage(Message):

738
    def __init__(self, f, n, fp):
739
        """Constructor."""
740 741 742 743 744
        Message.__init__(self, f, n, fp)
        if self.getmaintype() == 'multipart':
            self.body = Message.getbodyparts(self)
        else:
            self.body = Message.getbodytext(self)
745
        self.bodyencoded = Message.getbodytext(self, decode=0)
746
            # XXX If this is big, should remember file pointers
747

748
    def __repr__(self):
749
        """String representation."""
750 751
        f, n, fp = self.folder, self.number, self.fp
        return 'SubMessage(%s, %s, %s)' % (f, n, fp)
752

753 754 755
    def getbodytext(self, decode = 1):
        if not decode:
            return self.bodyencoded
756 757
        if type(self.body) == type(''):
            return self.body
758

759
    def getbodyparts(self):
760 761
        if type(self.body) == type([]):
            return self.body
762

763
    def getbody(self):
764
        return self.body
765 766 767


class IntSet:
768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788
    """Class implementing sets of integers.

    This is an efficient representation for sets consisting of several
    continuous ranges, e.g. 1-100,200-400,402-1000 is represented
    internally as a list of three pairs: [(1,100), (200,400),
    (402,1000)].  The internal representation is always kept normalized.

    The constructor has up to three arguments:
    - the string used to initialize the set (default ''),
    - the separator between ranges (default ',')
    - the separator between begin and end of a range (default '-')
    The separators must be strings (not regexprs) and should be different.

    The tostring() function yields a string that can be passed to another
    IntSet constructor; __repr__() is a valid IntSet constructor itself.
    """

    # XXX The default begin/end separator means that negative numbers are
    #     not supported very well.
    #
    # XXX There are currently no operations to remove set elements.
789

790
    def __init__(self, data = None, sep = ',', rng = '-'):
791 792 793
        self.pairs = []
        self.sep = sep
        self.rng = rng
794
        if data: self.fromstring(data)
795 796

    def reset(self):
797
        self.pairs = []
798 799

    def __cmp__(self, other):
800
        return cmp(self.pairs, other.pairs)
801 802

    def __hash__(self):
803
        return hash(self.pairs)
804 805

    def __repr__(self):
806
        return 'IntSet(%r, %r, %r)' % (self.tostring(), self.sep, self.rng)
807 808

    def normalize(self):
809 810 811 812 813 814 815 816 817
        self.pairs.sort()
        i = 1
        while i < len(self.pairs):
            alo, ahi = self.pairs[i-1]
            blo, bhi = self.pairs[i]
            if ahi >= blo-1:
                self.pairs[i-1:i+1] = [(alo, max(ahi, bhi))]
            else:
                i = i+1
818 819

    def tostring(self):
820 821
        s = ''
        for lo, hi in self.pairs:
822 823
            if lo == hi: t = repr(lo)
            else: t = repr(lo) + self.rng + repr(hi)
824 825 826
            if s: s = s + (self.sep + t)
            else: s = t
        return s
827 828

    def tolist(self):
829 830 831 832 833
        l = []
        for lo, hi in self.pairs:
            m = range(lo, hi+1)
            l = l + m
        return l
834 835

    def fromlist(self, list):
836 837
        for i in list:
            self.append(i)
838 839

    def clone(self):
840 841 842
        new = IntSet()
        new.pairs = self.pairs[:]
        return new
843 844

    def min(self):
845
        return self.pairs[0][0]
846 847

    def max(self):
848
        return self.pairs[-1][-1]
849 850

    def contains(self, x):
851
        for lo, hi in self.pairs:
852 853
            if lo <= x <= hi: return True
        return False
854 855

    def append(self, x):
856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878
        for i in range(len(self.pairs)):
            lo, hi = self.pairs[i]
            if x < lo: # Need to insert before
                if x+1 == lo:
                    self.pairs[i] = (x, hi)
                else:
                    self.pairs.insert(i, (x, x))
                if i > 0 and x-1 == self.pairs[i-1][1]:
                    # Merge with previous
                    self.pairs[i-1:i+1] = [
                            (self.pairs[i-1][0],
                             self.pairs[i][1])
                          ]
                return
            if x <= hi: # Already in set
                return
        i = len(self.pairs) - 1
        if i >= 0:
            lo, hi = self.pairs[i]
            if x-1 == hi:
                self.pairs[i] = lo, x
                return
        self.pairs.append((x, x))
879 880

    def addpair(self, xlo, xhi):
881 882 883
        if xlo > xhi: return
        self.pairs.append((xlo, xhi))
        self.normalize()
884 885

    def fromstring(self, data):
886
        new = []
887
        for part in data.split(self.sep):
888
            list = []
889 890 891
            for subp in part.split(self.rng):
                s = subp.strip()
                list.append(int(s))
892 893 894 895 896 897 898 899
            if len(list) == 1:
                new.append((list[0], list[0]))
            elif len(list) == 2 and list[0] <= list[1]:
                new.append((list[0], list[1]))
            else:
                raise ValueError, 'bad data passed to IntSet'
        self.pairs = self.pairs + new
        self.normalize()
900 901 902 903 904


# Subroutines to read/write entries in .mh_profile and .mh_sequences

def pickline(file, key, casefold = 1):
905
    try:
906
        f = open(file, 'r')
907
    except IOError:
908
        return None
909 910
    pat = re.escape(key) + ':'
    prog = re.compile(pat, casefold and re.IGNORECASE)
911
    while 1:
912 913 914 915 916 917
        line = f.readline()
        if not line: break
        if prog.match(line):
            text = line[len(key)+1:]
            while 1:
                line = f.readline()
Eric S. Raymond's avatar
Eric S. Raymond committed
918
                if not line or not line[0].isspace():
919 920
                    break
                text = text + line
921
            return text.strip()
922
    return None
923 924

def updateline(file, key, value, casefold = 1):
925
    try:
926 927 928
        f = open(file, 'r')
        lines = f.readlines()
        f.close()
929
    except IOError:
930
        lines = []
931 932
    pat = re.escape(key) + ':(.*)\n'
    prog = re.compile(pat, casefold and re.IGNORECASE)
933
    if value is None:
934
        newline = None
935
    else:
936
        newline = '%s: %s\n' % (key, value)
937
    for i in range(len(lines)):
938 939 940 941 942 943 944
        line = lines[i]
        if prog.match(line):
            if newline is None:
                del lines[i]
            else:
                lines[i] = newline
            break
945
    else:
946 947
        if newline is not None:
            lines.append(newline)
948 949 950
    tempfile = file + "~"
    f = open(tempfile, 'w')
    for line in lines:
951
        f.write(line)
952 953
    f.close()
    os.rename(tempfile, file)
954 955 956 957 958


# Test program

def test():
959 960 961 962 963 964 965
    global mh, f
    os.system('rm -rf $HOME/Mail/@test')
    mh = MH()
    def do(s): print s; print eval(s)
    do('mh.listfolders()')
    do('mh.listallfolders()')
    testfolders = ['@test', '@test/test1', '@test/test2',
966 967
                   '@test/test1/test11', '@test/test1/test12',
                   '@test/test1/test11/test111']
968
    for t in testfolders: do('mh.makefolder(%r)' % (t,))
969 970 971 972 973 974 975 976 977 978 979
    do('mh.listsubfolders(\'@test\')')
    do('mh.listallsubfolders(\'@test\')')
    f = mh.openfolder('@test')
    do('f.listsubfolders()')
    do('f.listallsubfolders()')
    do('f.getsequences()')
    seqs = f.getsequences()
    seqs['foo'] = IntSet('1-10 12-20', ' ').tolist()
    print seqs
    f.putsequences(seqs)
    do('f.getsequences()')
980
    for t in reversed(testfolders): do('mh.deletefolder(%r)' % (t,))
981 982 983 984
    do('mh.getcontext()')
    context = mh.getcontext()
    f = mh.openfolder(context)
    do('f.getcurrent()')
985
    for seq in ('first', 'last', 'cur', '.', 'prev', 'next',
986 987 988
                'first:3', 'last:3', 'cur:3', 'cur:-3',
                'prev:3', 'next:3',
                '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
989
                'all'):
990
        try:
991
            do('f.parsesequence(%r)' % (seq,))
992 993
        except Error, msg:
            print "Error:", msg
994
        stuff = os.popen("pick %r 2>/dev/null" % (seq,)).read()
995
        list = map(int, stuff.split())
996
        print list, "<-- pick"
997
    do('f.listmessages()')
998 999 1000


if __name__ == '__main__':
1001
    test()