1
"""MH interface -- purely object-oriented (well, almost)
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
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
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
22
mh.makefolder(name) # create new folder
23
mh.deletefolder(name) # delete folder -- must have no subfolders
25
f = mh.openfolder(name) # new open folder object
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
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)
39
dict = f.getsequences() # dictionary of sequences in folder {name: list}
40
f.putsequences(dict) # write sequences back to folder
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
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
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)
60
# XXX To do, functionality:
64
# XXX To do, organization:
65
# - move IntSet to separate file
66
# - move most Message functionality to module mimetools
69
# Customizable defaults
71
MH_PROFILE = '~/.mh_profile'
73
MH_SEQUENCES = '.mh_sequences'
85
from bisect import bisect
87
__all__ = ["MH","Error","Folder","Message"]
91
class Error(Exception):
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."""
102
def __init__(self, path = None, profile = None):
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'
115
"""String representation."""
116
return 'MH(%r, %r)' % (self.path, self.profile)
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))
122
def getprofile(self, key):
123
"""Return a profile entry, None if not found."""
124
return pickline(self.profile, key)
127
"""Return the path (the name of the collection's directory)."""
130
def getcontext(self):
131
"""Return the name of the current folder."""
132
context = pickline(os.path.join(self.getpath(), 'context'),
134
if not context: context = 'inbox'
137
def setcontext(self, context):
138
"""Set the name of the current folder."""
139
fn = os.path.join(self.getpath(), 'context')
141
f.write("Current-Folder: %s\n" % context)
144
def listfolders(self):
145
"""Return the names of the top-level 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):
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
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
179
def listallfolders(self):
180
"""Return the names of all folders and subfolders, recursively."""
181
return self.listallsubfolders('')
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
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(
202
subfolders = subfolders + subsubfolders
203
# Stop looking for subfolders when
204
# we've seen them all
211
def openfolder(self, name):
212
"""Return a new Folder object for the named folder."""
213
return Folder(self, name)
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)
221
mode = FOLDER_PROTECT
222
os.mkdir(os.path.join(self.getpath(), name), mode)
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)
231
os.unlink(fullsubname)
233
self.error('%s not deleted, continuing...' %
238
numericprog = re.compile('^[1-9][0-9]*$')
240
return numericprog.match(str) is not None
243
"""Class representing a particular folder."""
245
def __init__(self, mh, name):
249
if not os.path.isdir(self.getfullname()):
250
raise Error, 'no folder %s' % name
253
"""String representation."""
254
return 'Folder(%r, %r)' % (self.mh, self.name)
256
def error(self, *args):
257
"""Error message handler."""
260
def getfullname(self):
261
"""Return the full pathname of the folder."""
262
return os.path.join(self.mh.path, self.name)
264
def getsequencesfilename(self):
265
"""Return the full pathname of the folder's sequences file."""
266
return os.path.join(self.getfullname(), MH_SEQUENCES)
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))
272
def listsubfolders(self):
273
"""Return list of direct subfolders."""
274
return self.mh.listsubfolders(self.name)
276
def listallsubfolders(self):
277
"""Return list of all subfolders."""
278
return self.mh.listallsubfolders(self.name)
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)."""
284
match = numericprog.match
285
append = messages.append
286
for name in os.listdir(self.getfullname()):
289
messages = map(int, messages)
292
self.last = messages[-1]
297
def getsequences(self):
298
"""Return the set of sequences for the folder."""
300
fullname = self.getsequencesfilename()
302
f = open(fullname, 'r')
308
fields = line.split(':')
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
317
def putsequences(self, sequences):
318
"""Write the set of sequences back to the folder."""
319
fullname = self.getsequencesfilename()
321
for key, seq in sequences.iteritems():
324
if not f: f = open(fullname, 'w')
325
f.write('%s: %s\n' % (key, s.tostring()))
334
def getcurrent(self):
335
"""Return the current message. Raise Error when there is none."""
336
seqs = self.getsequences()
338
return max(seqs['cur'])
339
except (ValueError, KeyError):
340
raise Error, "no cur message"
342
def setcurrent(self, n):
343
"""Set the current message."""
344
updateline(self.getsequencesfilename(), 'cur', str(n), 0)
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)).
353
# - 'prev', 'next' as count
354
# - Sequence-Negation option
355
all = self.listmessages()
356
# Observed behavior: test for empty folder is done first
358
raise Error, "no messages in %s" % self.name
359
# Common case first: all is frequently the default
362
# Test for X:Y before X-Y because 'seq:-n' matches both
365
head, dir, tail = seq[:i], '', seq[i+1:]
367
dir, tail = tail[:1], tail[1:]
368
if not isnumeric(tail):
369
raise Error, "bad message list %s" % seq
372
except (ValueError, OverflowError):
373
# Can't use sys.maxint because of i+count below
376
anchor = self._parseindex(head, all)
378
seqs = self.getsequences()
381
msg = "bad message list %s" % seq
382
raise Error, msg, sys.exc_info()[2]
385
raise Error, "sequence %s empty" % head
392
if head in ('prev', 'last'):
395
i = bisect(all, anchor)
396
return all[max(0, i-count):i]
398
i = bisect(all, anchor-1)
399
return all[i:i+count]
403
begin = self._parseindex(seq[:i], all)
404
end = self._parseindex(seq[i+1:], all)
405
i = bisect(all, begin-1)
409
raise Error, "bad message list %s" % seq
411
# Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
413
n = self._parseindex(seq, all)
415
seqs = self.getsequences()
418
msg = "bad message list %s" % seq
424
raise Error, "message %d doesn't exist" % n
426
raise Error, "no %s message" % seq
430
def _parseindex(self, seq, all):
431
"""Internal: parse a message number (or cur, first, etc.)."""
435
except (OverflowError, ValueError):
437
if seq in ('cur', '.'):
438
return self.getcurrent()
444
n = self.getcurrent()
449
raise Error, "no next message"
451
n = self.getcurrent()
454
raise Error, "no prev message"
458
raise Error, "no prev message"
461
def openmessage(self, n):
462
"""Open a message -- returns a Message object."""
463
return Message(self, n)
465
def removemessages(self, list):
466
"""Remove one or more messages -- may raise os.error."""
470
path = self.getmessagefilename(n)
471
commapath = self.getmessagefilename(',' + str(n))
477
os.rename(path, commapath)
478
except os.error, msg:
483
self.removefromallsequences(deleted)
486
raise os.error, errors[0]
488
raise os.error, ('multiple errors:', errors)
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."""
496
ton = tofolder.getlast() + 1
497
path = self.getmessagefilename(n)
498
topath = tofolder.getmessagefilename(ton)
500
os.rename(path, topath)
504
shutil.copy2(path, topath)
506
except (IOError, os.error), msg:
513
tofolder.setlast(ton)
517
tofolder._copysequences(self, refiled.items())
518
self.removefromallsequences(refiled.keys())
521
raise os.error, errors[0]
523
raise os.error, ('multiple errors:', errors)
525
def _copysequences(self, fromfolder, refileditems):
526
"""Helper for refilemessages() to copy sequences."""
527
fromsequences = fromfolder.getsequences()
528
tosequences = self.getsequences()
530
for name, seq in fromsequences.items():
532
toseq = tosequences[name]
537
for fromn, ton in refileditems:
542
tosequences[name] = toseq
544
self.putsequences(tosequences)
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
554
topath = tofolder.getmessagefilename(ton)
555
backuptopath = tofolder.getmessagefilename(',%d' % ton)
557
os.rename(topath, backuptopath)
561
os.rename(path, topath)
566
tofolder.setlast(None)
567
shutil.copy2(path, topath)
576
self.removefromallsequences([n])
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
586
topath = tofolder.getmessagefilename(ton)
587
backuptopath = tofolder.getmessagefilename(',%d' % ton)
589
os.rename(topath, backuptopath)
594
tofolder.setlast(None)
595
shutil.copy2(path, topath)
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)
609
os.rename(path, backuppath)
617
buf = txt.read(BUFSIZE)
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:
635
sequences = self.getsequences()
637
for name, seq in sequences.items():
647
self.putsequences(sequences)
650
"""Return the last message number."""
651
if not hasattr(self, 'last'):
652
self.listmessages() # Set self.last
655
def setlast(self, last):
656
"""Set the last message number."""
658
if hasattr(self, 'last'):
663
class Message(mimetools.Message):
665
def __init__(self, f, n, fp = None):
670
path = f.getmessagefilename(n)
672
mimetools.Message.__init__(self, fp)
675
"""String representation."""
676
return 'Message(%s, %s)' % (repr(self.folder), self.number)
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)."""
684
return ''.join(self.headers)
687
for line in self.headers:
688
if not line[0].isspace():
691
hit = pred(line[:i].lower())
692
if hit: headers.append(line)
693
return ''.join(headers)
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()
705
from cStringIO import StringIO
707
from StringIO import StringIO
709
mimetools.decode(self.fp, output, encoding)
710
return output.getvalue()
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')
720
raise Error, 'multipart/* without boundary param'
721
self.fp.seek(self.startofbody)
722
mf = multifile.MultiFile(self.fp)
726
n = "%s.%r" % (self.number, 1 + len(parts))
727
part = SubMessage(self.folder, n, mf)
733
"""Return body, either a string or a list of messages."""
734
if self.getmaintype() == 'multipart':
735
return self.getbodyparts()
737
return self.getbodytext()
740
class SubMessage(Message):
742
def __init__(self, f, n, fp):
744
Message.__init__(self, f, n, fp)
745
if self.getmaintype() == 'multipart':
746
self.body = Message.getbodyparts(self)
748
self.body = Message.getbodytext(self)
749
self.bodyencoded = Message.getbodytext(self, decode=0)
750
# XXX If this is big, should remember file pointers
753
"""String representation."""
754
f, n, fp = self.folder, self.number, self.fp
755
return 'SubMessage(%s, %s, %s)' % (f, n, fp)
757
def getbodytext(self, decode = 1):
759
return self.bodyencoded
760
if type(self.body) == type(''):
763
def getbodyparts(self):
764
if type(self.body) == type([]):
772
"""Class implementing sets of integers.
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.
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.
785
The tostring() function yields a string that can be passed to another
786
IntSet constructor; __repr__() is a valid IntSet constructor itself.
789
# XXX The default begin/end separator means that negative numbers are
790
# not supported very well.
792
# XXX There are currently no operations to remove set elements.
794
def __init__(self, data = None, sep = ',', rng = '-'):
798
if data: self.fromstring(data)
803
def __cmp__(self, other):
804
return cmp(self.pairs, other.pairs)
807
return hash(self.pairs)
810
return 'IntSet(%r, %r, %r)' % (self.tostring(), self.sep, self.rng)
815
while i < len(self.pairs):
816
alo, ahi = self.pairs[i-1]
817
blo, bhi = self.pairs[i]
819
self.pairs[i-1:i+1] = [(alo, max(ahi, bhi))]
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)
834
for lo, hi in self.pairs:
839
def fromlist(self, list):
845
new.pairs = self.pairs[:]
849
return self.pairs[0][0]
852
return self.pairs[-1][-1]
854
def contains(self, x):
855
for lo, hi in self.pairs:
856
if lo <= x <= hi: return True
860
for i in range(len(self.pairs)):
861
lo, hi = self.pairs[i]
862
if x < lo: # Need to insert before
864
self.pairs[i] = (x, hi)
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] = [
874
if x <= hi: # Already in set
876
i = len(self.pairs) - 1
878
lo, hi = self.pairs[i]
880
self.pairs[i] = lo, x
882
self.pairs.append((x, x))
884
def addpair(self, xlo, xhi):
886
self.pairs.append((xlo, xhi))
889
def fromstring(self, data):
891
for part in data.split(self.sep):
893
for subp in part.split(self.rng):
897
new.append((list[0], list[0]))
898
elif len(list) == 2 and list[0] <= list[1]:
899
new.append((list[0], list[1]))
901
raise ValueError, 'bad data passed to IntSet'
902
self.pairs = self.pairs + new
906
# Subroutines to read/write entries in .mh_profile and .mh_sequences
908
def pickline(file, key, casefold = 1):
913
pat = re.escape(key) + ':'
914
prog = re.compile(pat, casefold and re.IGNORECASE)
919
text = line[len(key)+1:]
922
if not line or not line[0].isspace():
928
def updateline(file, key, value, casefold = 1):
931
lines = f.readlines()
935
pat = re.escape(key) + ':(.*)\n'
936
prog = re.compile(pat, casefold and re.IGNORECASE)
940
newline = '%s: %s\n' % (key, value)
941
for i in range(len(lines)):
950
if newline is not None:
951
lines.append(newline)
952
tempfile = file + "~"
953
f = open(tempfile, 'w')
957
os.rename(tempfile, file)
964
os.system('rm -rf $HOME/Mail/@test')
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()
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)
989
for seq in ('first', 'last', 'cur', '.', 'prev', 'next',
990
'first:3', 'last:3', 'cur:3', 'cur:-3',
992
'1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
995
do('f.parsesequence(%r)' % (seq,))
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()')
1004
if __name__ == '__main__':