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(%s, %s)' % (`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(%s, %s)' % (`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()
700 from StringIO
import StringIO
702 mimetools
.decode(self
.fp
, output
, encoding
)
703 return output
.getvalue()
705 def getbodyparts(self
):
706 """Only for multipart messages: return the message's body as a
707 list of SubMessage objects. Each submessage object behaves
708 (almost) as a Message object."""
709 if self
.getmaintype() != 'multipart':
710 raise Error
, 'Content-Type is not multipart/*'
711 bdry
= self
.getparam('boundary')
713 raise Error
, 'multipart/* without boundary param'
714 self
.fp
.seek(self
.startofbody
)
715 mf
= multifile
.MultiFile(self
.fp
)
719 n
= str(self
.number
) + '.' + `
1 + len(parts
)`
720 part
= SubMessage(self
.folder
, n
, mf
)
726 """Return body, either a string or a list of messages."""
727 if self
.getmaintype() == 'multipart':
728 return self
.getbodyparts()
730 return self
.getbodytext()
733 class SubMessage(Message
):
735 def __init__(self
, f
, n
, fp
):
737 Message
.__init
__(self
, f
, n
, fp
)
738 if self
.getmaintype() == 'multipart':
739 self
.body
= Message
.getbodyparts(self
)
741 self
.body
= Message
.getbodytext(self
)
742 self
.bodyencoded
= Message
.getbodytext(self
, decode
=0)
743 # XXX If this is big, should remember file pointers
746 """String representation."""
747 f
, n
, fp
= self
.folder
, self
.number
, self
.fp
748 return 'SubMessage(%s, %s, %s)' % (f
, n
, fp
)
750 def getbodytext(self
, decode
= 1):
752 return self
.bodyencoded
753 if type(self
.body
) == type(''):
756 def getbodyparts(self
):
757 if type(self
.body
) == type([]):
765 """Class implementing sets of integers.
767 This is an efficient representation for sets consisting of several
768 continuous ranges, e.g. 1-100,200-400,402-1000 is represented
769 internally as a list of three pairs: [(1,100), (200,400),
770 (402,1000)]. The internal representation is always kept normalized.
772 The constructor has up to three arguments:
773 - the string used to initialize the set (default ''),
774 - the separator between ranges (default ',')
775 - the separator between begin and end of a range (default '-')
776 The separators must be strings (not regexprs) and should be different.
778 The tostring() function yields a string that can be passed to another
779 IntSet constructor; __repr__() is a valid IntSet constructor itself.
782 # XXX The default begin/end separator means that negative numbers are
783 # not supported very well.
785 # XXX There are currently no operations to remove set elements.
787 def __init__(self
, data
= None, sep
= ',', rng
= '-'):
791 if data
: self
.fromstring(data
)
796 def __cmp__(self
, other
):
797 return cmp(self
.pairs
, other
.pairs
)
800 return hash(self
.pairs
)
803 return 'IntSet(%s, %s, %s)' % (`self
.tostring()`
,
804 `self
.sep`
, `self
.rng`
)
809 while i
< len(self
.pairs
):
810 alo
, ahi
= self
.pairs
[i
-1]
811 blo
, bhi
= self
.pairs
[i
]
813 self
.pairs
[i
-1:i
+1] = [(alo
, max(ahi
, bhi
))]
819 for lo
, hi
in self
.pairs
:
820 if lo
== hi
: t
= `lo`
821 else: t
= `lo`
+ self
.rng
+ `hi`
822 if s
: s
= s
+ (self
.sep
+ t
)
828 for lo
, hi
in self
.pairs
:
833 def fromlist(self
, list):
839 new
.pairs
= self
.pairs
[:]
843 return self
.pairs
[0][0]
846 return self
.pairs
[-1][-1]
848 def contains(self
, x
):
849 for lo
, hi
in self
.pairs
:
850 if lo
<= x
<= hi
: return True
854 for i
in range(len(self
.pairs
)):
855 lo
, hi
= self
.pairs
[i
]
856 if x
< lo
: # Need to insert before
858 self
.pairs
[i
] = (x
, hi
)
860 self
.pairs
.insert(i
, (x
, x
))
861 if i
> 0 and x
-1 == self
.pairs
[i
-1][1]:
862 # Merge with previous
863 self
.pairs
[i
-1:i
+1] = [
868 if x
<= hi
: # Already in set
870 i
= len(self
.pairs
) - 1
872 lo
, hi
= self
.pairs
[i
]
874 self
.pairs
[i
] = lo
, x
876 self
.pairs
.append((x
, x
))
878 def addpair(self
, xlo
, xhi
):
880 self
.pairs
.append((xlo
, xhi
))
883 def fromstring(self
, data
):
885 for part
in data
.split(self
.sep
):
887 for subp
in part
.split(self
.rng
):
891 new
.append((list[0], list[0]))
892 elif len(list) == 2 and list[0] <= list[1]:
893 new
.append((list[0], list[1]))
895 raise ValueError, 'bad data passed to IntSet'
896 self
.pairs
= self
.pairs
+ new
900 # Subroutines to read/write entries in .mh_profile and .mh_sequences
902 def pickline(file, key
, casefold
= 1):
907 pat
= re
.escape(key
) + ':'
908 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
913 text
= line
[len(key
)+1:]
916 if not line
or not line
[0].isspace():
922 def updateline(file, key
, value
, casefold
= 1):
925 lines
= f
.readlines()
929 pat
= re
.escape(key
) + ':(.*)\n'
930 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
934 newline
= '%s: %s\n' % (key
, value
)
935 for i
in range(len(lines
)):
944 if newline
is not None:
945 lines
.append(newline
)
946 tempfile
= file + "~"
947 f
= open(tempfile
, 'w')
951 os
.rename(tempfile
, file)
958 os
.system('rm -rf $HOME/Mail/@test')
960 def do(s
): print s
; print eval(s
)
961 do('mh.listfolders()')
962 do('mh.listallfolders()')
963 testfolders
= ['@test', '@test/test1', '@test/test2',
964 '@test/test1/test11', '@test/test1/test12',
965 '@test/test1/test11/test111']
966 for t
in testfolders
: do('mh.makefolder(%s)' % `t`
)
967 do('mh.listsubfolders(\'@test\')')
968 do('mh.listallsubfolders(\'@test\')')
969 f
= mh
.openfolder('@test')
970 do('f.listsubfolders()')
971 do('f.listallsubfolders()')
972 do('f.getsequences()')
973 seqs
= f
.getsequences()
974 seqs
['foo'] = IntSet('1-10 12-20', ' ').tolist()
977 do('f.getsequences()')
978 testfolders
.reverse()
979 for t
in testfolders
: do('mh.deletefolder(%s)' % `t`
)
980 do('mh.getcontext()')
981 context
= mh
.getcontext()
982 f
= mh
.openfolder(context
)
984 for seq
in ['first', 'last', 'cur', '.', 'prev', 'next',
985 'first:3', 'last:3', 'cur:3', 'cur:-3',
987 '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
990 do('f.parsesequence(%s)' % `seq`
)
993 stuff
= os
.popen("pick %s 2>/dev/null" % `seq`
).read()
994 list = map(int, stuff
.split())
995 print list, "<-- pick"
996 do('f.listmessages()')
999 if __name__
== '__main__':