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
56 # XXX To do, functionality:
60 # XXX To do, organization:
61 # - move IntSet to separate file
62 # - move most Message functionality to module mimetools
65 # Customizable defaults
67 MH_PROFILE
= '~/.mh_profile'
69 MH_SEQUENCES
= '.mh_sequences'
81 from bisect
import bisect
83 __all__
= ["MH","Error","Folder","Message"]
87 class Error(Exception):
92 """Class representing a particular collection of folders.
93 Optional constructor arguments are the pathname for the directory
94 containing the collection, and the MH profile to use.
95 If either is omitted or empty a default is used; the default
96 directory is taken from the MH profile if it is specified there."""
98 def __init__(self
, path
= None, profile
= None):
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 if not path
: path
= PATH
104 if not os
.path
.isabs(path
) and path
[0] != '~':
105 path
= os
.path
.join('~', path
)
106 path
= os
.path
.expanduser(path
)
107 if not os
.path
.isdir(path
): raise Error
, 'MH() path not found'
111 """String representation."""
112 return 'MH(%r, %r)' % (self
.path
, self
.profile
)
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
))
118 def getprofile(self
, key
):
119 """Return a profile entry, None if not found."""
120 return pickline(self
.profile
, key
)
123 """Return the path (the name of the collection's directory)."""
126 def getcontext(self
):
127 """Return the name of the current folder."""
128 context
= pickline(os
.path
.join(self
.getpath(), 'context'),
130 if not context
: context
= 'inbox'
133 def setcontext(self
, context
):
134 """Set the name of the current folder."""
135 fn
= os
.path
.join(self
.getpath(), 'context')
137 f
.write("Current-Folder: %s\n" % context
)
140 def listfolders(self
):
141 """Return the names of the top-level folders."""
143 path
= self
.getpath()
144 for name
in os
.listdir(path
):
145 fullname
= os
.path
.join(path
, name
)
146 if os
.path
.isdir(fullname
):
151 def listsubfolders(self
, name
):
152 """Return the names of the subfolders in a given folder
153 (prefixed with the given folder name)."""
154 fullname
= os
.path
.join(self
.path
, name
)
155 # Get the link count so we can avoid listing folders
156 # that have no subfolders.
157 nlinks
= os
.stat(fullname
).st_nlink
161 subnames
= os
.listdir(fullname
)
162 for subname
in subnames
:
163 fullsubname
= os
.path
.join(fullname
, subname
)
164 if os
.path
.isdir(fullsubname
):
165 name_subname
= os
.path
.join(name
, subname
)
166 subfolders
.append(name_subname
)
167 # Stop looking for subfolders when
168 # we've seen them all
175 def listallfolders(self
):
176 """Return the names of all folders and subfolders, recursively."""
177 return self
.listallsubfolders('')
179 def listallsubfolders(self
, name
):
180 """Return the names of subfolders in a given folder, recursively."""
181 fullname
= os
.path
.join(self
.path
, name
)
182 # Get the link count so we can avoid listing folders
183 # that have no subfolders.
184 nlinks
= os
.stat(fullname
).st_nlink
188 subnames
= os
.listdir(fullname
)
189 for subname
in subnames
:
190 if subname
[0] == ',' or isnumeric(subname
): continue
191 fullsubname
= os
.path
.join(fullname
, subname
)
192 if os
.path
.isdir(fullsubname
):
193 name_subname
= os
.path
.join(name
, subname
)
194 subfolders
.append(name_subname
)
195 if not os
.path
.islink(fullsubname
):
196 subsubfolders
= self
.listallsubfolders(
198 subfolders
= subfolders
+ subsubfolders
199 # Stop looking for subfolders when
200 # we've seen them all
207 def openfolder(self
, name
):
208 """Return a new Folder object for the named folder."""
209 return Folder(self
, name
)
211 def makefolder(self
, name
):
212 """Create a new folder (or raise os.error if it cannot be created)."""
213 protect
= pickline(self
.profile
, 'Folder-Protect')
214 if protect
and isnumeric(protect
):
215 mode
= int(protect
, 8)
217 mode
= FOLDER_PROTECT
218 os
.mkdir(os
.path
.join(self
.getpath(), name
), mode
)
220 def deletefolder(self
, name
):
221 """Delete a folder. This removes files in the folder but not
222 subdirectories. Raise os.error if deleting the folder itself fails."""
223 fullname
= os
.path
.join(self
.getpath(), name
)
224 for subname
in os
.listdir(fullname
):
225 fullsubname
= os
.path
.join(fullname
, subname
)
227 os
.unlink(fullsubname
)
229 self
.error('%s not deleted, continuing...' %
234 numericprog
= re
.compile('^[1-9][0-9]*$')
236 return numericprog
.match(str) is not None
239 """Class representing a particular folder."""
241 def __init__(self
, mh
, name
):
245 if not os
.path
.isdir(self
.getfullname()):
246 raise Error
, 'no folder %s' % name
249 """String representation."""
250 return 'Folder(%r, %r)' % (self
.mh
, self
.name
)
252 def error(self
, *args
):
253 """Error message handler."""
256 def getfullname(self
):
257 """Return the full pathname of the folder."""
258 return os
.path
.join(self
.mh
.path
, self
.name
)
260 def getsequencesfilename(self
):
261 """Return the full pathname of the folder's sequences file."""
262 return os
.path
.join(self
.getfullname(), MH_SEQUENCES
)
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
))
268 def listsubfolders(self
):
269 """Return list of direct subfolders."""
270 return self
.mh
.listsubfolders(self
.name
)
272 def listallsubfolders(self
):
273 """Return list of all subfolders."""
274 return self
.mh
.listallsubfolders(self
.name
)
276 def listmessages(self
):
277 """Return the list of messages currently present in the folder.
278 As a side effect, set self.last to the last message (or 0)."""
280 match
= numericprog
.match
281 append
= messages
.append
282 for name
in os
.listdir(self
.getfullname()):
285 messages
= map(int, messages
)
288 self
.last
= messages
[-1]
293 def getsequences(self
):
294 """Return the set of sequences for the folder."""
296 fullname
= self
.getsequencesfilename()
298 f
= open(fullname
, 'r')
304 fields
= line
.split(':')
306 self
.error('bad sequence in %s: %s' %
307 (fullname
, line
.strip()))
308 key
= fields
[0].strip()
309 value
= IntSet(fields
[1].strip(), ' ').tolist()
310 sequences
[key
] = value
313 def putsequences(self
, sequences
):
314 """Write the set of sequences back to the folder."""
315 fullname
= self
.getsequencesfilename()
317 for key
, seq
in sequences
.iteritems():
320 if not f
: f
= open(fullname
, 'w')
321 f
.write('%s: %s\n' % (key
, s
.tostring()))
330 def getcurrent(self
):
331 """Return the current message. Raise Error when there is none."""
332 seqs
= self
.getsequences()
334 return max(seqs
['cur'])
335 except (ValueError, KeyError):
336 raise Error
, "no cur message"
338 def setcurrent(self
, n
):
339 """Set the current message."""
340 updateline(self
.getsequencesfilename(), 'cur', str(n
), 0)
342 def parsesequence(self
, seq
):
343 """Parse an MH sequence specification into a message list.
344 Attempt to mimic mh-sequence(5) as close as possible.
345 Also attempt to mimic observed behavior regarding which
346 conditions cause which error messages."""
347 # XXX Still not complete (see mh-format(5)).
349 # - 'prev', 'next' as count
350 # - Sequence-Negation option
351 all
= self
.listmessages()
352 # Observed behavior: test for empty folder is done first
354 raise Error
, "no messages in %s" % self
.name
355 # Common case first: all is frequently the default
358 # Test for X:Y before X-Y because 'seq:-n' matches both
361 head
, dir, tail
= seq
[:i
], '', seq
[i
+1:]
363 dir, tail
= tail
[:1], tail
[1:]
364 if not isnumeric(tail
):
365 raise Error
, "bad message list %s" % seq
368 except (ValueError, OverflowError):
369 # Can't use sys.maxint because of i+count below
372 anchor
= self
._parseindex
(head
, all
)
374 seqs
= self
.getsequences()
377 msg
= "bad message list %s" % seq
378 raise Error
, msg
, sys
.exc_info()[2]
381 raise Error
, "sequence %s empty" % head
388 if head
in ('prev', 'last'):
391 i
= bisect(all
, anchor
)
392 return all
[max(0, i
-count
):i
]
394 i
= bisect(all
, anchor
-1)
395 return all
[i
:i
+count
]
399 begin
= self
._parseindex
(seq
[:i
], all
)
400 end
= self
._parseindex
(seq
[i
+1:], all
)
401 i
= bisect(all
, begin
-1)
405 raise Error
, "bad message list %s" % seq
407 # Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
409 n
= self
._parseindex
(seq
, all
)
411 seqs
= self
.getsequences()
414 msg
= "bad message list %s" % seq
420 raise Error
, "message %d doesn't exist" % n
422 raise Error
, "no %s message" % seq
426 def _parseindex(self
, seq
, all
):
427 """Internal: parse a message number (or cur, first, etc.)."""
431 except (OverflowError, ValueError):
433 if seq
in ('cur', '.'):
434 return self
.getcurrent()
440 n
= self
.getcurrent()
445 raise Error
, "no next message"
447 n
= self
.getcurrent()
450 raise Error
, "no prev message"
454 raise Error
, "no prev message"
457 def openmessage(self
, n
):
458 """Open a message -- returns a Message object."""
459 return Message(self
, n
)
461 def removemessages(self
, list):
462 """Remove one or more messages -- may raise os.error."""
466 path
= self
.getmessagefilename(n
)
467 commapath
= self
.getmessagefilename(',' + str(n
))
473 os
.rename(path
, commapath
)
474 except os
.error
, msg
:
479 self
.removefromallsequences(deleted
)
482 raise os
.error
, errors
[0]
484 raise os
.error
, ('multiple errors:', errors
)
486 def refilemessages(self
, list, tofolder
, keepsequences
=0):
487 """Refile one or more messages -- may raise os.error.
488 'tofolder' is an open folder object."""
492 ton
= tofolder
.getlast() + 1
493 path
= self
.getmessagefilename(n
)
494 topath
= tofolder
.getmessagefilename(ton
)
496 os
.rename(path
, topath
)
500 shutil
.copy2(path
, topath
)
502 except (IOError, os
.error
), msg
:
509 tofolder
.setlast(ton
)
513 tofolder
._copysequences
(self
, refiled
.items())
514 self
.removefromallsequences(refiled
.keys())
517 raise os
.error
, errors
[0]
519 raise os
.error
, ('multiple errors:', errors
)
521 def _copysequences(self
, fromfolder
, refileditems
):
522 """Helper for refilemessages() to copy sequences."""
523 fromsequences
= fromfolder
.getsequences()
524 tosequences
= self
.getsequences()
526 for name
, seq
in fromsequences
.items():
528 toseq
= tosequences
[name
]
533 for fromn
, ton
in refileditems
:
538 tosequences
[name
] = toseq
540 self
.putsequences(tosequences
)
542 def movemessage(self
, n
, tofolder
, ton
):
543 """Move one message over a specific destination message,
544 which may or may not already exist."""
545 path
= self
.getmessagefilename(n
)
546 # Open it to check that it exists
550 topath
= tofolder
.getmessagefilename(ton
)
551 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
553 os
.rename(topath
, backuptopath
)
557 os
.rename(path
, topath
)
562 tofolder
.setlast(None)
563 shutil
.copy2(path
, topath
)
572 self
.removefromallsequences([n
])
574 def copymessage(self
, n
, tofolder
, ton
):
575 """Copy one message over a specific destination message,
576 which may or may not already exist."""
577 path
= self
.getmessagefilename(n
)
578 # Open it to check that it exists
582 topath
= tofolder
.getmessagefilename(ton
)
583 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
585 os
.rename(topath
, backuptopath
)
590 tofolder
.setlast(None)
591 shutil
.copy2(path
, topath
)
600 def createmessage(self
, n
, txt
):
601 """Create a message, with text from the open file txt."""
602 path
= self
.getmessagefilename(n
)
603 backuppath
= self
.getmessagefilename(',%d' % n
)
605 os
.rename(path
, backuppath
)
613 buf
= txt
.read(BUFSIZE
)
626 def removefromallsequences(self
, list):
627 """Remove one or more messages from all sequences (including last)
628 -- but not from 'cur'!!!"""
629 if hasattr(self
, 'last') and self
.last
in list:
631 sequences
= self
.getsequences()
633 for name
, seq
in sequences
.items():
643 self
.putsequences(sequences
)
646 """Return the last message number."""
647 if not hasattr(self
, 'last'):
648 self
.listmessages() # Set self.last
651 def setlast(self
, last
):
652 """Set the last message number."""
654 if hasattr(self
, 'last'):
659 class Message(mimetools
.Message
):
661 def __init__(self
, f
, n
, fp
= None):
666 path
= f
.getmessagefilename(n
)
668 mimetools
.Message
.__init
__(self
, fp
)
671 """String representation."""
672 return 'Message(%s, %s)' % (repr(self
.folder
), self
.number
)
674 def getheadertext(self
, pred
= None):
675 """Return the message's header text as a string. If an
676 argument is specified, it is used as a filter predicate to
677 decide which headers to return (its argument is the header
678 name converted to lower case)."""
680 return ''.join(self
.headers
)
683 for line
in self
.headers
:
684 if not line
[0].isspace():
687 hit
= pred(line
[:i
].lower())
688 if hit
: headers
.append(line
)
689 return ''.join(headers
)
691 def getbodytext(self
, decode
= 1):
692 """Return the message's body text as string. This undoes a
693 Content-Transfer-Encoding, but does not interpret other MIME
694 features (e.g. multipart messages). To suppress decoding,
695 pass 0 as an argument."""
696 self
.fp
.seek(self
.startofbody
)
697 encoding
= self
.getencoding()
698 if not decode
or encoding
in ('', '7bit', '8bit', 'binary'):
699 return self
.fp
.read()
701 from cStringIO
import StringIO
703 from StringIO
import StringIO
705 mimetools
.decode(self
.fp
, output
, encoding
)
706 return output
.getvalue()
708 def getbodyparts(self
):
709 """Only for multipart messages: return the message's body as a
710 list of SubMessage objects. Each submessage object behaves
711 (almost) as a Message object."""
712 if self
.getmaintype() != 'multipart':
713 raise Error
, 'Content-Type is not multipart/*'
714 bdry
= self
.getparam('boundary')
716 raise Error
, 'multipart/* without boundary param'
717 self
.fp
.seek(self
.startofbody
)
718 mf
= multifile
.MultiFile(self
.fp
)
722 n
= "%s.%r" % (self
.number
, 1 + len(parts
))
723 part
= SubMessage(self
.folder
, n
, mf
)
729 """Return body, either a string or a list of messages."""
730 if self
.getmaintype() == 'multipart':
731 return self
.getbodyparts()
733 return self
.getbodytext()
736 class SubMessage(Message
):
738 def __init__(self
, f
, n
, fp
):
740 Message
.__init
__(self
, f
, n
, fp
)
741 if self
.getmaintype() == 'multipart':
742 self
.body
= Message
.getbodyparts(self
)
744 self
.body
= Message
.getbodytext(self
)
745 self
.bodyencoded
= Message
.getbodytext(self
, decode
=0)
746 # XXX If this is big, should remember file pointers
749 """String representation."""
750 f
, n
, fp
= self
.folder
, self
.number
, self
.fp
751 return 'SubMessage(%s, %s, %s)' % (f
, n
, fp
)
753 def getbodytext(self
, decode
= 1):
755 return self
.bodyencoded
756 if type(self
.body
) == type(''):
759 def getbodyparts(self
):
760 if type(self
.body
) == type([]):
768 """Class implementing sets of integers.
770 This is an efficient representation for sets consisting of several
771 continuous ranges, e.g. 1-100,200-400,402-1000 is represented
772 internally as a list of three pairs: [(1,100), (200,400),
773 (402,1000)]. The internal representation is always kept normalized.
775 The constructor has up to three arguments:
776 - the string used to initialize the set (default ''),
777 - the separator between ranges (default ',')
778 - the separator between begin and end of a range (default '-')
779 The separators must be strings (not regexprs) and should be different.
781 The tostring() function yields a string that can be passed to another
782 IntSet constructor; __repr__() is a valid IntSet constructor itself.
785 # XXX The default begin/end separator means that negative numbers are
786 # not supported very well.
788 # XXX There are currently no operations to remove set elements.
790 def __init__(self
, data
= None, sep
= ',', rng
= '-'):
794 if data
: self
.fromstring(data
)
799 def __cmp__(self
, other
):
800 return cmp(self
.pairs
, other
.pairs
)
803 return hash(self
.pairs
)
806 return 'IntSet(%r, %r, %r)' % (self
.tostring(), self
.sep
, self
.rng
)
811 while i
< len(self
.pairs
):
812 alo
, ahi
= self
.pairs
[i
-1]
813 blo
, bhi
= self
.pairs
[i
]
815 self
.pairs
[i
-1:i
+1] = [(alo
, max(ahi
, bhi
))]
821 for lo
, hi
in self
.pairs
:
822 if lo
== hi
: t
= repr(lo
)
823 else: t
= repr(lo
) + self
.rng
+ repr(hi
)
824 if s
: s
= s
+ (self
.sep
+ t
)
830 for lo
, hi
in self
.pairs
:
835 def fromlist(self
, list):
841 new
.pairs
= self
.pairs
[:]
845 return self
.pairs
[0][0]
848 return self
.pairs
[-1][-1]
850 def contains(self
, x
):
851 for lo
, hi
in self
.pairs
:
852 if lo
<= x
<= hi
: return True
856 for i
in range(len(self
.pairs
)):
857 lo
, hi
= self
.pairs
[i
]
858 if x
< lo
: # Need to insert before
860 self
.pairs
[i
] = (x
, hi
)
862 self
.pairs
.insert(i
, (x
, x
))
863 if i
> 0 and x
-1 == self
.pairs
[i
-1][1]:
864 # Merge with previous
865 self
.pairs
[i
-1:i
+1] = [
870 if x
<= hi
: # Already in set
872 i
= len(self
.pairs
) - 1
874 lo
, hi
= self
.pairs
[i
]
876 self
.pairs
[i
] = lo
, x
878 self
.pairs
.append((x
, x
))
880 def addpair(self
, xlo
, xhi
):
882 self
.pairs
.append((xlo
, xhi
))
885 def fromstring(self
, data
):
887 for part
in data
.split(self
.sep
):
889 for subp
in part
.split(self
.rng
):
893 new
.append((list[0], list[0]))
894 elif len(list) == 2 and list[0] <= list[1]:
895 new
.append((list[0], list[1]))
897 raise ValueError, 'bad data passed to IntSet'
898 self
.pairs
= self
.pairs
+ new
902 # Subroutines to read/write entries in .mh_profile and .mh_sequences
904 def pickline(file, key
, casefold
= 1):
909 pat
= re
.escape(key
) + ':'
910 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
915 text
= line
[len(key
)+1:]
918 if not line
or not line
[0].isspace():
924 def updateline(file, key
, value
, casefold
= 1):
927 lines
= f
.readlines()
931 pat
= re
.escape(key
) + ':(.*)\n'
932 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
936 newline
= '%s: %s\n' % (key
, value
)
937 for i
in range(len(lines
)):
946 if newline
is not None:
947 lines
.append(newline
)
948 tempfile
= file + "~"
949 f
= open(tempfile
, 'w')
953 os
.rename(tempfile
, file)
960 os
.system('rm -rf $HOME/Mail/@test')
962 def do(s
): print s
; print eval(s
)
963 do('mh.listfolders()')
964 do('mh.listallfolders()')
965 testfolders
= ['@test', '@test/test1', '@test/test2',
966 '@test/test1/test11', '@test/test1/test12',
967 '@test/test1/test11/test111']
968 for t
in testfolders
: do('mh.makefolder(%r)' % (t
,))
969 do('mh.listsubfolders(\'@test\')')
970 do('mh.listallsubfolders(\'@test\')')
971 f
= mh
.openfolder('@test')
972 do('f.listsubfolders()')
973 do('f.listallsubfolders()')
974 do('f.getsequences()')
975 seqs
= f
.getsequences()
976 seqs
['foo'] = IntSet('1-10 12-20', ' ').tolist()
979 do('f.getsequences()')
980 for t
in reversed(testfolders
): do('mh.deletefolder(%r)' % (t
,))
981 do('mh.getcontext()')
982 context
= mh
.getcontext()
983 f
= mh
.openfolder(context
)
985 for seq
in ('first', 'last', 'cur', '.', 'prev', 'next',
986 'first:3', 'last:3', 'cur:3', 'cur:-3',
988 '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
991 do('f.parsesequence(%r)' % (seq
,))
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()')
1000 if __name__
== '__main__':