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'
77 from stat
import ST_NLINK
83 from bisect
import bisect
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 not profile
: profile
= MH_PROFILE
101 self
.profile
= os
.path
.expanduser(profile
)
102 if not path
: 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 st
= os
.stat(fullname
)
158 nlinks
= st
[ST_NLINK
]
162 subnames
= os
.listdir(fullname
)
163 for subname
in subnames
:
164 fullsubname
= os
.path
.join(fullname
, subname
)
165 if os
.path
.isdir(fullsubname
):
166 name_subname
= os
.path
.join(name
, subname
)
167 subfolders
.append(name_subname
)
168 # Stop looking for subfolders when
169 # we've seen them all
176 def listallfolders(self
):
177 """Return the names of all folders and subfolders, recursively."""
178 return self
.listallsubfolders('')
180 def listallsubfolders(self
, name
):
181 """Return the names of subfolders in a given folder, recursively."""
182 fullname
= os
.path
.join(self
.path
, name
)
183 # Get the link count so we can avoid listing folders
184 # that have no subfolders.
185 st
= os
.stat(fullname
)
186 nlinks
= st
[ST_NLINK
]
190 subnames
= os
.listdir(fullname
)
191 for subname
in subnames
:
192 if subname
[0] == ',' or isnumeric(subname
): continue
193 fullsubname
= os
.path
.join(fullname
, subname
)
194 if os
.path
.isdir(fullsubname
):
195 name_subname
= os
.path
.join(name
, subname
)
196 subfolders
.append(name_subname
)
197 if not os
.path
.islink(fullsubname
):
198 subsubfolders
= self
.listallsubfolders(
200 subfolders
= subfolders
+ subsubfolders
201 # Stop looking for subfolders when
202 # we've seen them all
209 def openfolder(self
, name
):
210 """Return a new Folder object for the named folder."""
211 return Folder(self
, name
)
213 def makefolder(self
, name
):
214 """Create a new folder (or raise os.error if it cannot be created)."""
215 protect
= pickline(self
.profile
, 'Folder-Protect')
216 if protect
and isnumeric(protect
):
217 mode
= string
.atoi(protect
, 8)
219 mode
= FOLDER_PROTECT
220 os
.mkdir(os
.path
.join(self
.getpath(), name
), mode
)
222 def deletefolder(self
, name
):
223 """Delete a folder. This removes files in the folder but not
224 subdirectories. Raise os.error if deleting the folder itself fails."""
225 fullname
= os
.path
.join(self
.getpath(), name
)
226 for subname
in os
.listdir(fullname
):
227 fullsubname
= os
.path
.join(fullname
, subname
)
229 os
.unlink(fullsubname
)
231 self
.error('%s not deleted, continuing...' %
236 numericprog
= re
.compile('^[1-9][0-9]*$')
238 return numericprog
.match(str) is not None
241 """Class representing a particular folder."""
243 def __init__(self
, mh
, name
):
247 if not os
.path
.isdir(self
.getfullname()):
248 raise Error
, 'no folder %s' % name
251 """String representation."""
252 return 'Folder(%s, %s)' % (`self
.mh`
, `self
.name`
)
254 def error(self
, *args
):
255 """Error message handler."""
256 apply(self
.mh
.error
, args
)
258 def getfullname(self
):
259 """Return the full pathname of the folder."""
260 return os
.path
.join(self
.mh
.path
, self
.name
)
262 def getsequencesfilename(self
):
263 """Return the full pathname of the folder's sequences file."""
264 return os
.path
.join(self
.getfullname(), MH_SEQUENCES
)
266 def getmessagefilename(self
, n
):
267 """Return the full pathname of a message in the folder."""
268 return os
.path
.join(self
.getfullname(), str(n
))
270 def listsubfolders(self
):
271 """Return list of direct subfolders."""
272 return self
.mh
.listsubfolders(self
.name
)
274 def listallsubfolders(self
):
275 """Return list of all subfolders."""
276 return self
.mh
.listallsubfolders(self
.name
)
278 def listmessages(self
):
279 """Return the list of messages currently present in the folder.
280 As a side effect, set self.last to the last message (or 0)."""
282 match
= numericprog
.match
283 append
= messages
.append
284 for name
in os
.listdir(self
.getfullname()):
287 messages
= map(string
.atoi
, messages
)
290 self
.last
= messages
[-1]
295 def getsequences(self
):
296 """Return the set of sequences for the folder."""
298 fullname
= self
.getsequencesfilename()
300 f
= open(fullname
, 'r')
306 fields
= string
.splitfields(line
, ':')
308 self
.error('bad sequence in %s: %s' %
309 (fullname
, string
.strip(line
)))
310 key
= string
.strip(fields
[0])
311 value
= IntSet(string
.strip(fields
[1]), ' ').tolist()
312 sequences
[key
] = value
315 def putsequences(self
, sequences
):
316 """Write the set of sequences back to the folder."""
317 fullname
= self
.getsequencesfilename()
319 for key
in sequences
.keys():
321 s
.fromlist(sequences
[key
])
322 if not f
: f
= open(fullname
, 'w')
323 f
.write('%s: %s\n' % (key
, s
.tostring()))
332 def getcurrent(self
):
333 """Return the current message. Raise KeyError when there is none."""
334 seqs
= self
.getsequences()
336 return max(seqs
['cur'])
337 except (ValueError, KeyError):
338 raise Error
, "no cur message"
340 def setcurrent(self
, n
):
341 """Set the current message."""
342 updateline(self
.getsequencesfilename(), 'cur', str(n
), 0)
344 def parsesequence(self
, seq
):
345 """Parse an MH sequence specification into a message list.
346 Attempt to mimic mh-sequence(5) as close as possible.
347 Also attempt to mimic observed behavior regarding which
348 conditions cause which error messages."""
349 # XXX Still not complete (see mh-format(5)).
351 # - 'prev', 'next' as count
352 # - Sequence-Negation option
353 all
= self
.listmessages()
354 # Observed behavior: test for empty folder is done first
356 raise Error
, "no messages in %s" % self
.name
357 # Common case first: all is frequently the default
360 # Test for X:Y before X-Y because 'seq:-n' matches both
361 i
= string
.find(seq
, ':')
363 head
, dir, tail
= seq
[:i
], '', seq
[i
+1:]
365 dir, tail
= tail
[:1], tail
[1:]
366 if not isnumeric(tail
):
367 raise Error
, "bad message list %s" % seq
369 count
= string
.atoi(tail
)
370 except (ValueError, OverflowError):
371 # Can't use sys.maxint because of i+count below
374 anchor
= self
._parseindex
(head
, all
)
376 seqs
= self
.getsequences()
377 if not seqs
.has_key(head
):
379 msg
= "bad message list %s" % seq
380 raise Error
, msg
, sys
.exc_info()[2]
383 raise Error
, "sequence %s empty" % head
390 if head
in ('prev', 'last'):
393 i
= bisect(all
, anchor
)
394 return all
[max(0, i
-count
):i
]
396 i
= bisect(all
, anchor
-1)
397 return all
[i
:i
+count
]
399 i
= string
.find(seq
, '-')
401 begin
= self
._parseindex
(seq
[:i
], all
)
402 end
= self
._parseindex
(seq
[i
+1:], all
)
403 i
= bisect(all
, begin
-1)
407 raise Error
, "bad message list %s" % seq
409 # Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
411 n
= self
._parseindex
(seq
, all
)
413 seqs
= self
.getsequences()
414 if not seqs
.has_key(seq
):
416 msg
= "bad message list %s" % seq
422 raise Error
, "message %d doesn't exist" % n
424 raise Error
, "no %s message" % seq
428 def _parseindex(self
, seq
, all
):
429 """Internal: parse a message number (or cur, first, etc.)."""
432 return string
.atoi(seq
)
433 except (OverflowError, ValueError):
435 if seq
in ('cur', '.'):
436 return self
.getcurrent()
442 n
= self
.getcurrent()
447 raise Error
, "no next message"
449 n
= self
.getcurrent()
452 raise Error
, "no prev message"
456 raise Error
, "no prev message"
459 def openmessage(self
, n
):
460 """Open a message -- returns a Message object."""
461 return Message(self
, n
)
463 def removemessages(self
, list):
464 """Remove one or more messages -- may raise os.error."""
468 path
= self
.getmessagefilename(n
)
469 commapath
= self
.getmessagefilename(',' + str(n
))
475 os
.rename(path
, commapath
)
476 except os
.error
, msg
:
481 self
.removefromallsequences(deleted
)
484 raise os
.error
, errors
[0]
486 raise os
.error
, ('multiple errors:', errors
)
488 def refilemessages(self
, list, tofolder
, keepsequences
=0):
489 """Refile one or more messages -- may raise os.error.
490 'tofolder' is an open folder object."""
494 ton
= tofolder
.getlast() + 1
495 path
= self
.getmessagefilename(n
)
496 topath
= tofolder
.getmessagefilename(ton
)
498 os
.rename(path
, topath
)
502 shutil
.copy2(path
, topath
)
504 except (IOError, os
.error
), msg
:
511 tofolder
.setlast(ton
)
515 tofolder
._copysequences
(self
, refiled
.items())
516 self
.removefromallsequences(refiled
.keys())
519 raise os
.error
, errors
[0]
521 raise os
.error
, ('multiple errors:', errors
)
523 def _copysequences(self
, fromfolder
, refileditems
):
524 """Helper for refilemessages() to copy sequences."""
525 fromsequences
= fromfolder
.getsequences()
526 tosequences
= self
.getsequences()
528 for name
, seq
in fromsequences
.items():
530 toseq
= tosequences
[name
]
535 for fromn
, ton
in refileditems
:
540 tosequences
[name
] = toseq
542 self
.putsequences(tosequences
)
544 def movemessage(self
, n
, tofolder
, ton
):
545 """Move one message over a specific destination message,
546 which may or may not already exist."""
547 path
= self
.getmessagefilename(n
)
548 # Open it to check that it exists
552 topath
= tofolder
.getmessagefilename(ton
)
553 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
555 os
.rename(topath
, backuptopath
)
559 os
.rename(path
, topath
)
564 tofolder
.setlast(None)
565 shutil
.copy2(path
, topath
)
574 self
.removefromallsequences([n
])
576 def copymessage(self
, n
, tofolder
, ton
):
577 """Copy one message over a specific destination message,
578 which may or may not already exist."""
579 path
= self
.getmessagefilename(n
)
580 # Open it to check that it exists
584 topath
= tofolder
.getmessagefilename(ton
)
585 backuptopath
= tofolder
.getmessagefilename(',%d' % ton
)
587 os
.rename(topath
, backuptopath
)
592 tofolder
.setlast(None)
593 shutil
.copy2(path
, topath
)
602 def createmessage(self
, n
, txt
):
603 """Create a message, with text from the open file txt."""
604 path
= self
.getmessagefilename(n
)
605 backuppath
= self
.getmessagefilename(',%d' % n
)
607 os
.rename(path
, backuppath
)
615 buf
= txt
.read(BUFSIZE
)
628 def removefromallsequences(self
, list):
629 """Remove one or more messages from all sequeuces (including last)
630 -- but not from 'cur'!!!"""
631 if hasattr(self
, 'last') and self
.last
in list:
633 sequences
= self
.getsequences()
635 for name
, seq
in sequences
.items():
645 self
.putsequences(sequences
)
648 """Return the last message number."""
649 if not hasattr(self
, 'last'):
650 messages
= self
.listmessages()
653 def setlast(self
, last
):
654 """Set the last message number."""
656 if hasattr(self
, 'last'):
661 class Message(mimetools
.Message
):
663 def __init__(self
, f
, n
, fp
= None):
668 path
= f
.getmessagefilename(n
)
670 mimetools
.Message
.__init
__(self
, fp
)
673 """String representation."""
674 return 'Message(%s, %s)' % (repr(self
.folder
), self
.number
)
676 def getheadertext(self
, pred
= None):
677 """Return the message's header text as a string. If an
678 argument is specified, it is used as a filter predicate to
679 decide which headers to return (its argument is the header
680 name converted to lower case)."""
682 return string
.joinfields(self
.headers
, '')
685 for line
in self
.headers
:
686 if line
[0] not in string
.whitespace
:
687 i
= string
.find(line
, ':')
689 hit
= pred(string
.lower(line
[:i
]))
690 if hit
: headers
.append(line
)
691 return string
.joinfields(headers
, '')
693 def getbodytext(self
, decode
= 1):
694 """Return the message's body text as string. This undoes a
695 Content-Transfer-Encoding, but does not interpret other MIME
696 features (e.g. multipart messages). To suppress decoding,
697 pass 0 as an argument."""
698 self
.fp
.seek(self
.startofbody
)
699 encoding
= self
.getencoding()
700 if not decode
or encoding
in ('', '7bit', '8bit', 'binary'):
701 return self
.fp
.read()
702 from StringIO
import StringIO
704 mimetools
.decode(self
.fp
, output
, encoding
)
705 return output
.getvalue()
707 def getbodyparts(self
):
708 """Only for multipart messages: return the message's body as a
709 list of SubMessage objects. Each submessage object behaves
710 (almost) as a Message object."""
711 if self
.getmaintype() != 'multipart':
712 raise Error
, 'Content-Type is not multipart/*'
713 bdry
= self
.getparam('boundary')
715 raise Error
, 'multipart/* without boundary param'
716 self
.fp
.seek(self
.startofbody
)
717 mf
= multifile
.MultiFile(self
.fp
)
721 n
= str(self
.number
) + '.' + `
1 + len(parts
)`
722 part
= SubMessage(self
.folder
, n
, mf
)
728 """Return body, either a string or a list of messages."""
729 if self
.getmaintype() == 'multipart':
730 return self
.getbodyparts()
732 return self
.getbodytext()
735 class SubMessage(Message
):
737 def __init__(self
, f
, n
, fp
):
739 Message
.__init
__(self
, f
, n
, fp
)
740 if self
.getmaintype() == 'multipart':
741 self
.body
= Message
.getbodyparts(self
)
743 self
.body
= Message
.getbodytext(self
)
744 self
.bodyencoded
= Message
.getbodytext(self
, decode
=0)
745 # XXX If this is big, should remember file pointers
748 """String representation."""
749 f
, n
, fp
= self
.folder
, self
.number
, self
.fp
750 return 'SubMessage(%s, %s, %s)' % (f
, n
, fp
)
752 def getbodytext(self
, decode
= 1):
754 return self
.bodyencoded
755 if type(self
.body
) == type(''):
758 def getbodyparts(self
):
759 if type(self
.body
) == type([]):
767 """Class implementing sets of integers.
769 This is an efficient representation for sets consisting of several
770 continuous ranges, e.g. 1-100,200-400,402-1000 is represented
771 internally as a list of three pairs: [(1,100), (200,400),
772 (402,1000)]. The internal representation is always kept normalized.
774 The constructor has up to three arguments:
775 - the string used to initialize the set (default ''),
776 - the separator between ranges (default ',')
777 - the separator between begin and end of a range (default '-')
778 The separators must be strings (not regexprs) and should be different.
780 The tostring() function yields a string that can be passed to another
781 IntSet constructor; __repr__() is a valid IntSet constructor itself.
784 # XXX The default begin/end separator means that negative numbers are
785 # not supported very well.
787 # XXX There are currently no operations to remove set elements.
789 def __init__(self
, data
= None, sep
= ',', rng
= '-'):
793 if data
: self
.fromstring(data
)
798 def __cmp__(self
, other
):
799 return cmp(self
.pairs
, other
.pairs
)
802 return hash(self
.pairs
)
805 return 'IntSet(%s, %s, %s)' % (`self
.tostring()`
,
806 `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
= `lo`
823 else: t
= `lo`
+ self
.rng
+ `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 1
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
):
888 for part
in string
.splitfields(data
, self
.sep
):
890 for subp
in string
.splitfields(part
, self
.rng
):
891 s
= string
.strip(subp
)
892 list.append(string
.atoi(s
))
894 new
.append((list[0], list[0]))
895 elif len(list) == 2 and list[0] <= list[1]:
896 new
.append((list[0], list[1]))
898 raise ValueError, 'bad data passed to IntSet'
899 self
.pairs
= self
.pairs
+ new
903 # Subroutines to read/write entries in .mh_profile and .mh_sequences
905 def pickline(file, key
, casefold
= 1):
910 pat
= re
.escape(key
) + ':'
911 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
916 text
= line
[len(key
)+1:]
919 if not line
or line
[0] not in string
.whitespace
:
922 return string
.strip(text
)
925 def updateline(file, key
, value
, casefold
= 1):
928 lines
= f
.readlines()
932 pat
= re
.escape(key
) + ':(.*)\n'
933 prog
= re
.compile(pat
, casefold
and re
.IGNORECASE
)
937 newline
= '%s: %s\n' % (key
, value
)
938 for i
in range(len(lines
)):
947 if newline
is not None:
948 lines
.append(newline
)
949 tempfile
= file + "~"
950 f
= open(tempfile
, 'w')
954 os
.rename(tempfile
, file)
961 os
.system('rm -rf $HOME/Mail/@test')
963 def do(s
): print s
; print eval(s
)
964 do('mh.listfolders()')
965 do('mh.listallfolders()')
966 testfolders
= ['@test', '@test/test1', '@test/test2',
967 '@test/test1/test11', '@test/test1/test12',
968 '@test/test1/test11/test111']
969 for t
in testfolders
: do('mh.makefolder(%s)' % `t`
)
970 do('mh.listsubfolders(\'@test\')')
971 do('mh.listallsubfolders(\'@test\')')
972 f
= mh
.openfolder('@test')
973 do('f.listsubfolders()')
974 do('f.listallsubfolders()')
975 do('f.getsequences()')
976 seqs
= f
.getsequences()
977 seqs
['foo'] = IntSet('1-10 12-20', ' ').tolist()
980 do('f.getsequences()')
981 testfolders
.reverse()
982 for t
in testfolders
: do('mh.deletefolder(%s)' % `t`
)
983 do('mh.getcontext()')
984 context
= mh
.getcontext()
985 f
= mh
.openfolder(context
)
987 for seq
in ['first', 'last', 'cur', '.', 'prev', 'next',
988 'first:3', 'last:3', 'cur:3', 'cur:-3',
990 '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
993 do('f.parsesequence(%s)' % `seq`
)
996 stuff
= os
.popen("pick %s 2>/dev/null" % `seq`
).read()
997 list = map(string
.atoi
, string
.split(stuff
))
998 print list, "<-- pick"
999 do('f.listmessages()')
1002 if __name__
== '__main__':