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__':