]> git.lizzy.rs Git - plan9front.git/blob - sys/lib/python/mhlib.py
dist/mkfile: run binds in subshell
[plan9front.git] / sys / lib / python / mhlib.py
1 """MH interface -- purely object-oriented (well, almost)
2
3 Executive summary:
4
5 import mhlib
6
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
10
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
16
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
21
22 mh.makefolder(name)     # create new folder
23 mh.deletefolder(name)   # delete folder -- must have no subfolders
24
25 f = mh.openfolder(name) # new open folder object
26
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
31
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)
38
39 dict = f.getsequences() # dictionary of sequences in folder {name: list}
40 f.putsequences(dict)    # write sequences back to folder
41
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
47
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
54 """
55
56 # XXX To do, functionality:
57 # - annotate messages
58 # - send messages
59 #
60 # XXX To do, organization:
61 # - move IntSet to separate file
62 # - move most Message functionality to module mimetools
63
64
65 # Customizable defaults
66
67 MH_PROFILE = '~/.mh_profile'
68 PATH = '~/Mail'
69 MH_SEQUENCES = '.mh_sequences'
70 FOLDER_PROTECT = 0700
71
72
73 # Imported modules
74
75 import os
76 import sys
77 import re
78 import mimetools
79 import multifile
80 import shutil
81 from bisect import bisect
82
83 __all__ = ["MH","Error","Folder","Message"]
84
85 # Exported constants
86
87 class Error(Exception):
88     pass
89
90
91 class MH:
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."""
97
98     def __init__(self, path = None, profile = None):
99         """Constructor."""
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'
108         self.path = path
109
110     def __repr__(self):
111         """String representation."""
112         return 'MH(%r, %r)' % (self.path, self.profile)
113
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))
117
118     def getprofile(self, key):
119         """Return a profile entry, None if not found."""
120         return pickline(self.profile, key)
121
122     def getpath(self):
123         """Return the path (the name of the collection's directory)."""
124         return self.path
125
126     def getcontext(self):
127         """Return the name of the current folder."""
128         context = pickline(os.path.join(self.getpath(), 'context'),
129                   'Current-Folder')
130         if not context: context = 'inbox'
131         return context
132
133     def setcontext(self, context):
134         """Set the name of the current folder."""
135         fn = os.path.join(self.getpath(), 'context')
136         f = open(fn, "w")
137         f.write("Current-Folder: %s\n" % context)
138         f.close()
139
140     def listfolders(self):
141         """Return the names of the top-level folders."""
142         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):
147                 folders.append(name)
148         folders.sort()
149         return folders
150
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
158         if nlinks <= 2:
159             return []
160         subfolders = []
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
169                 nlinks = nlinks - 1
170                 if nlinks <= 2:
171                     break
172         subfolders.sort()
173         return subfolders
174
175     def listallfolders(self):
176         """Return the names of all folders and subfolders, recursively."""
177         return self.listallsubfolders('')
178
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
185         if nlinks <= 2:
186             return []
187         subfolders = []
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(
197                               name_subname)
198                     subfolders = subfolders + subsubfolders
199                 # Stop looking for subfolders when
200                 # we've seen them all
201                 nlinks = nlinks - 1
202                 if nlinks <= 2:
203                     break
204         subfolders.sort()
205         return subfolders
206
207     def openfolder(self, name):
208         """Return a new Folder object for the named folder."""
209         return Folder(self, name)
210
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)
216         else:
217             mode = FOLDER_PROTECT
218         os.mkdir(os.path.join(self.getpath(), name), mode)
219
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)
226             try:
227                 os.unlink(fullsubname)
228             except os.error:
229                 self.error('%s not deleted, continuing...' %
230                           fullsubname)
231         os.rmdir(fullname)
232
233
234 numericprog = re.compile('^[1-9][0-9]*$')
235 def isnumeric(str):
236     return numericprog.match(str) is not None
237
238 class Folder:
239     """Class representing a particular folder."""
240
241     def __init__(self, mh, name):
242         """Constructor."""
243         self.mh = mh
244         self.name = name
245         if not os.path.isdir(self.getfullname()):
246             raise Error, 'no folder %s' % name
247
248     def __repr__(self):
249         """String representation."""
250         return 'Folder(%r, %r)' % (self.mh, self.name)
251
252     def error(self, *args):
253         """Error message handler."""
254         self.mh.error(*args)
255
256     def getfullname(self):
257         """Return the full pathname of the folder."""
258         return os.path.join(self.mh.path, self.name)
259
260     def getsequencesfilename(self):
261         """Return the full pathname of the folder's sequences file."""
262         return os.path.join(self.getfullname(), MH_SEQUENCES)
263
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))
267
268     def listsubfolders(self):
269         """Return list of direct subfolders."""
270         return self.mh.listsubfolders(self.name)
271
272     def listallsubfolders(self):
273         """Return list of all subfolders."""
274         return self.mh.listallsubfolders(self.name)
275
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)."""
279         messages = []
280         match = numericprog.match
281         append = messages.append
282         for name in os.listdir(self.getfullname()):
283             if match(name):
284                 append(name)
285         messages = map(int, messages)
286         messages.sort()
287         if messages:
288             self.last = messages[-1]
289         else:
290             self.last = 0
291         return messages
292
293     def getsequences(self):
294         """Return the set of sequences for the folder."""
295         sequences = {}
296         fullname = self.getsequencesfilename()
297         try:
298             f = open(fullname, 'r')
299         except IOError:
300             return sequences
301         while 1:
302             line = f.readline()
303             if not line: break
304             fields = line.split(':')
305             if len(fields) != 2:
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
311         return sequences
312
313     def putsequences(self, sequences):
314         """Write the set of sequences back to the folder."""
315         fullname = self.getsequencesfilename()
316         f = None
317         for key, seq in sequences.iteritems():
318             s = IntSet('', ' ')
319             s.fromlist(seq)
320             if not f: f = open(fullname, 'w')
321             f.write('%s: %s\n' % (key, s.tostring()))
322         if not f:
323             try:
324                 os.unlink(fullname)
325             except os.error:
326                 pass
327         else:
328             f.close()
329
330     def getcurrent(self):
331         """Return the current message.  Raise Error when there is none."""
332         seqs = self.getsequences()
333         try:
334             return max(seqs['cur'])
335         except (ValueError, KeyError):
336             raise Error, "no cur message"
337
338     def setcurrent(self, n):
339         """Set the current message."""
340         updateline(self.getsequencesfilename(), 'cur', str(n), 0)
341
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)).
348         # Missing are:
349         # - 'prev', 'next' as count
350         # - Sequence-Negation option
351         all = self.listmessages()
352         # Observed behavior: test for empty folder is done first
353         if not all:
354             raise Error, "no messages in %s" % self.name
355         # Common case first: all is frequently the default
356         if seq == 'all':
357             return all
358         # Test for X:Y before X-Y because 'seq:-n' matches both
359         i = seq.find(':')
360         if i >= 0:
361             head, dir, tail = seq[:i], '', seq[i+1:]
362             if tail[:1] in '-+':
363                 dir, tail = tail[:1], tail[1:]
364             if not isnumeric(tail):
365                 raise Error, "bad message list %s" % seq
366             try:
367                 count = int(tail)
368             except (ValueError, OverflowError):
369                 # Can't use sys.maxint because of i+count below
370                 count = len(all)
371             try:
372                 anchor = self._parseindex(head, all)
373             except Error, msg:
374                 seqs = self.getsequences()
375                 if not head in seqs:
376                     if not msg:
377                         msg = "bad message list %s" % seq
378                     raise Error, msg, sys.exc_info()[2]
379                 msgs = seqs[head]
380                 if not msgs:
381                     raise Error, "sequence %s empty" % head
382                 if dir == '-':
383                     return msgs[-count:]
384                 else:
385                     return msgs[:count]
386             else:
387                 if not dir:
388                     if head in ('prev', 'last'):
389                         dir = '-'
390                 if dir == '-':
391                     i = bisect(all, anchor)
392                     return all[max(0, i-count):i]
393                 else:
394                     i = bisect(all, anchor-1)
395                     return all[i:i+count]
396         # Test for X-Y next
397         i = seq.find('-')
398         if i >= 0:
399             begin = self._parseindex(seq[:i], all)
400             end = self._parseindex(seq[i+1:], all)
401             i = bisect(all, begin-1)
402             j = bisect(all, end)
403             r = all[i:j]
404             if not r:
405                 raise Error, "bad message list %s" % seq
406             return r
407         # Neither X:Y nor X-Y; must be a number or a (pseudo-)sequence
408         try:
409             n = self._parseindex(seq, all)
410         except Error, msg:
411             seqs = self.getsequences()
412             if not seq in seqs:
413                 if not msg:
414                     msg = "bad message list %s" % seq
415                 raise Error, msg
416             return seqs[seq]
417         else:
418             if n not in all:
419                 if isnumeric(seq):
420                     raise Error, "message %d doesn't exist" % n
421                 else:
422                     raise Error, "no %s message" % seq
423             else:
424                 return [n]
425
426     def _parseindex(self, seq, all):
427         """Internal: parse a message number (or cur, first, etc.)."""
428         if isnumeric(seq):
429             try:
430                 return int(seq)
431             except (OverflowError, ValueError):
432                 return sys.maxint
433         if seq in ('cur', '.'):
434             return self.getcurrent()
435         if seq == 'first':
436             return all[0]
437         if seq == 'last':
438             return all[-1]
439         if seq == 'next':
440             n = self.getcurrent()
441             i = bisect(all, n)
442             try:
443                 return all[i]
444             except IndexError:
445                 raise Error, "no next message"
446         if seq == 'prev':
447             n = self.getcurrent()
448             i = bisect(all, n-1)
449             if i == 0:
450                 raise Error, "no prev message"
451             try:
452                 return all[i-1]
453             except IndexError:
454                 raise Error, "no prev message"
455         raise Error, None
456
457     def openmessage(self, n):
458         """Open a message -- returns a Message object."""
459         return Message(self, n)
460
461     def removemessages(self, list):
462         """Remove one or more messages -- may raise os.error."""
463         errors = []
464         deleted = []
465         for n in list:
466             path = self.getmessagefilename(n)
467             commapath = self.getmessagefilename(',' + str(n))
468             try:
469                 os.unlink(commapath)
470             except os.error:
471                 pass
472             try:
473                 os.rename(path, commapath)
474             except os.error, msg:
475                 errors.append(msg)
476             else:
477                 deleted.append(n)
478         if deleted:
479             self.removefromallsequences(deleted)
480         if errors:
481             if len(errors) == 1:
482                 raise os.error, errors[0]
483             else:
484                 raise os.error, ('multiple errors:', errors)
485
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."""
489         errors = []
490         refiled = {}
491         for n in list:
492             ton = tofolder.getlast() + 1
493             path = self.getmessagefilename(n)
494             topath = tofolder.getmessagefilename(ton)
495             try:
496                 os.rename(path, topath)
497             except os.error:
498                 # Try copying
499                 try:
500                     shutil.copy2(path, topath)
501                     os.unlink(path)
502                 except (IOError, os.error), msg:
503                     errors.append(msg)
504                     try:
505                         os.unlink(topath)
506                     except os.error:
507                         pass
508                     continue
509             tofolder.setlast(ton)
510             refiled[n] = ton
511         if refiled:
512             if keepsequences:
513                 tofolder._copysequences(self, refiled.items())
514             self.removefromallsequences(refiled.keys())
515         if errors:
516             if len(errors) == 1:
517                 raise os.error, errors[0]
518             else:
519                 raise os.error, ('multiple errors:', errors)
520
521     def _copysequences(self, fromfolder, refileditems):
522         """Helper for refilemessages() to copy sequences."""
523         fromsequences = fromfolder.getsequences()
524         tosequences = self.getsequences()
525         changed = 0
526         for name, seq in fromsequences.items():
527             try:
528                 toseq = tosequences[name]
529                 new = 0
530             except KeyError:
531                 toseq = []
532                 new = 1
533             for fromn, ton in refileditems:
534                 if fromn in seq:
535                     toseq.append(ton)
536                     changed = 1
537             if new and toseq:
538                 tosequences[name] = toseq
539         if changed:
540             self.putsequences(tosequences)
541
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
547         f = open(path)
548         f.close()
549         del f
550         topath = tofolder.getmessagefilename(ton)
551         backuptopath = tofolder.getmessagefilename(',%d' % ton)
552         try:
553             os.rename(topath, backuptopath)
554         except os.error:
555             pass
556         try:
557             os.rename(path, topath)
558         except os.error:
559             # Try copying
560             ok = 0
561             try:
562                 tofolder.setlast(None)
563                 shutil.copy2(path, topath)
564                 ok = 1
565             finally:
566                 if not ok:
567                     try:
568                         os.unlink(topath)
569                     except os.error:
570                         pass
571             os.unlink(path)
572         self.removefromallsequences([n])
573
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
579         f = open(path)
580         f.close()
581         del f
582         topath = tofolder.getmessagefilename(ton)
583         backuptopath = tofolder.getmessagefilename(',%d' % ton)
584         try:
585             os.rename(topath, backuptopath)
586         except os.error:
587             pass
588         ok = 0
589         try:
590             tofolder.setlast(None)
591             shutil.copy2(path, topath)
592             ok = 1
593         finally:
594             if not ok:
595                 try:
596                     os.unlink(topath)
597                 except os.error:
598                     pass
599
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)
604         try:
605             os.rename(path, backuppath)
606         except os.error:
607             pass
608         ok = 0
609         BUFSIZE = 16*1024
610         try:
611             f = open(path, "w")
612             while 1:
613                 buf = txt.read(BUFSIZE)
614                 if not buf:
615                     break
616                 f.write(buf)
617             f.close()
618             ok = 1
619         finally:
620             if not ok:
621                 try:
622                     os.unlink(path)
623                 except os.error:
624                     pass
625
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:
630             del self.last
631         sequences = self.getsequences()
632         changed = 0
633         for name, seq in sequences.items():
634             if name == 'cur':
635                 continue
636             for n in list:
637                 if n in seq:
638                     seq.remove(n)
639                     changed = 1
640                     if not seq:
641                         del sequences[name]
642         if changed:
643             self.putsequences(sequences)
644
645     def getlast(self):
646         """Return the last message number."""
647         if not hasattr(self, 'last'):
648             self.listmessages() # Set self.last
649         return self.last
650
651     def setlast(self, last):
652         """Set the last message number."""
653         if last is None:
654             if hasattr(self, 'last'):
655                 del self.last
656         else:
657             self.last = last
658
659 class Message(mimetools.Message):
660
661     def __init__(self, f, n, fp = None):
662         """Constructor."""
663         self.folder = f
664         self.number = n
665         if fp is None:
666             path = f.getmessagefilename(n)
667             fp = open(path, 'r')
668         mimetools.Message.__init__(self, fp)
669
670     def __repr__(self):
671         """String representation."""
672         return 'Message(%s, %s)' % (repr(self.folder), self.number)
673
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)."""
679         if pred is None:
680             return ''.join(self.headers)
681         headers = []
682         hit = 0
683         for line in self.headers:
684             if not line[0].isspace():
685                 i = line.find(':')
686                 if i > 0:
687                     hit = pred(line[:i].lower())
688             if hit: headers.append(line)
689         return ''.join(headers)
690
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         try:
701             from cStringIO import StringIO
702         except ImportError:
703             from StringIO import StringIO
704         output = StringIO()
705         mimetools.decode(self.fp, output, encoding)
706         return output.getvalue()
707
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')
715         if not bdry:
716             raise Error, 'multipart/* without boundary param'
717         self.fp.seek(self.startofbody)
718         mf = multifile.MultiFile(self.fp)
719         mf.push(bdry)
720         parts = []
721         while mf.next():
722             n = "%s.%r" % (self.number, 1 + len(parts))
723             part = SubMessage(self.folder, n, mf)
724             parts.append(part)
725         mf.pop()
726         return parts
727
728     def getbody(self):
729         """Return body, either a string or a list of messages."""
730         if self.getmaintype() == 'multipart':
731             return self.getbodyparts()
732         else:
733             return self.getbodytext()
734
735
736 class SubMessage(Message):
737
738     def __init__(self, f, n, fp):
739         """Constructor."""
740         Message.__init__(self, f, n, fp)
741         if self.getmaintype() == 'multipart':
742             self.body = Message.getbodyparts(self)
743         else:
744             self.body = Message.getbodytext(self)
745         self.bodyencoded = Message.getbodytext(self, decode=0)
746             # XXX If this is big, should remember file pointers
747
748     def __repr__(self):
749         """String representation."""
750         f, n, fp = self.folder, self.number, self.fp
751         return 'SubMessage(%s, %s, %s)' % (f, n, fp)
752
753     def getbodytext(self, decode = 1):
754         if not decode:
755             return self.bodyencoded
756         if type(self.body) == type(''):
757             return self.body
758
759     def getbodyparts(self):
760         if type(self.body) == type([]):
761             return self.body
762
763     def getbody(self):
764         return self.body
765
766
767 class IntSet:
768     """Class implementing sets of integers.
769
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.
774
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.
780
781     The tostring() function yields a string that can be passed to another
782     IntSet constructor; __repr__() is a valid IntSet constructor itself.
783     """
784
785     # XXX The default begin/end separator means that negative numbers are
786     #     not supported very well.
787     #
788     # XXX There are currently no operations to remove set elements.
789
790     def __init__(self, data = None, sep = ',', rng = '-'):
791         self.pairs = []
792         self.sep = sep
793         self.rng = rng
794         if data: self.fromstring(data)
795
796     def reset(self):
797         self.pairs = []
798
799     def __cmp__(self, other):
800         return cmp(self.pairs, other.pairs)
801
802     def __hash__(self):
803         return hash(self.pairs)
804
805     def __repr__(self):
806         return 'IntSet(%r, %r, %r)' % (self.tostring(), self.sep, self.rng)
807
808     def normalize(self):
809         self.pairs.sort()
810         i = 1
811         while i < len(self.pairs):
812             alo, ahi = self.pairs[i-1]
813             blo, bhi = self.pairs[i]
814             if ahi >= blo-1:
815                 self.pairs[i-1:i+1] = [(alo, max(ahi, bhi))]
816             else:
817                 i = i+1
818
819     def tostring(self):
820         s = ''
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)
825             else: s = t
826         return s
827
828     def tolist(self):
829         l = []
830         for lo, hi in self.pairs:
831             m = range(lo, hi+1)
832             l = l + m
833         return l
834
835     def fromlist(self, list):
836         for i in list:
837             self.append(i)
838
839     def clone(self):
840         new = IntSet()
841         new.pairs = self.pairs[:]
842         return new
843
844     def min(self):
845         return self.pairs[0][0]
846
847     def max(self):
848         return self.pairs[-1][-1]
849
850     def contains(self, x):
851         for lo, hi in self.pairs:
852             if lo <= x <= hi: return True
853         return False
854
855     def append(self, x):
856         for i in range(len(self.pairs)):
857             lo, hi = self.pairs[i]
858             if x < lo: # Need to insert before
859                 if x+1 == lo:
860                     self.pairs[i] = (x, hi)
861                 else:
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] = [
866                             (self.pairs[i-1][0],
867                              self.pairs[i][1])
868                           ]
869                 return
870             if x <= hi: # Already in set
871                 return
872         i = len(self.pairs) - 1
873         if i >= 0:
874             lo, hi = self.pairs[i]
875             if x-1 == hi:
876                 self.pairs[i] = lo, x
877                 return
878         self.pairs.append((x, x))
879
880     def addpair(self, xlo, xhi):
881         if xlo > xhi: return
882         self.pairs.append((xlo, xhi))
883         self.normalize()
884
885     def fromstring(self, data):
886         new = []
887         for part in data.split(self.sep):
888             list = []
889             for subp in part.split(self.rng):
890                 s = subp.strip()
891                 list.append(int(s))
892             if len(list) == 1:
893                 new.append((list[0], list[0]))
894             elif len(list) == 2 and list[0] <= list[1]:
895                 new.append((list[0], list[1]))
896             else:
897                 raise ValueError, 'bad data passed to IntSet'
898         self.pairs = self.pairs + new
899         self.normalize()
900
901
902 # Subroutines to read/write entries in .mh_profile and .mh_sequences
903
904 def pickline(file, key, casefold = 1):
905     try:
906         f = open(file, 'r')
907     except IOError:
908         return None
909     pat = re.escape(key) + ':'
910     prog = re.compile(pat, casefold and re.IGNORECASE)
911     while 1:
912         line = f.readline()
913         if not line: break
914         if prog.match(line):
915             text = line[len(key)+1:]
916             while 1:
917                 line = f.readline()
918                 if not line or not line[0].isspace():
919                     break
920                 text = text + line
921             return text.strip()
922     return None
923
924 def updateline(file, key, value, casefold = 1):
925     try:
926         f = open(file, 'r')
927         lines = f.readlines()
928         f.close()
929     except IOError:
930         lines = []
931     pat = re.escape(key) + ':(.*)\n'
932     prog = re.compile(pat, casefold and re.IGNORECASE)
933     if value is None:
934         newline = None
935     else:
936         newline = '%s: %s\n' % (key, value)
937     for i in range(len(lines)):
938         line = lines[i]
939         if prog.match(line):
940             if newline is None:
941                 del lines[i]
942             else:
943                 lines[i] = newline
944             break
945     else:
946         if newline is not None:
947             lines.append(newline)
948     tempfile = file + "~"
949     f = open(tempfile, 'w')
950     for line in lines:
951         f.write(line)
952     f.close()
953     os.rename(tempfile, file)
954
955
956 # Test program
957
958 def test():
959     global mh, f
960     os.system('rm -rf $HOME/Mail/@test')
961     mh = MH()
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()
977     print seqs
978     f.putsequences(seqs)
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)
984     do('f.getcurrent()')
985     for seq in ('first', 'last', 'cur', '.', 'prev', 'next',
986                 'first:3', 'last:3', 'cur:3', 'cur:-3',
987                 'prev:3', 'next:3',
988                 '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3',
989                 'all'):
990         try:
991             do('f.parsesequence(%r)' % (seq,))
992         except Error, msg:
993             print "Error:", msg
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()')
998
999
1000 if __name__ == '__main__':
1001     test()