Commit | Line | Data |
---|---|---|
86530b38 AT |
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 | from StringIO import StringIO | |
701 | output = StringIO() | |
702 | mimetools.decode(self.fp, output, encoding) | |
703 | return output.getvalue() | |
704 | ||
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') | |
712 | if not bdry: | |
713 | raise Error, 'multipart/* without boundary param' | |
714 | self.fp.seek(self.startofbody) | |
715 | mf = multifile.MultiFile(self.fp) | |
716 | mf.push(bdry) | |
717 | parts = [] | |
718 | while mf.next(): | |
719 | n = "%s.%r" % (self.number, 1 + len(parts)) | |
720 | part = SubMessage(self.folder, n, mf) | |
721 | parts.append(part) | |
722 | mf.pop() | |
723 | return parts | |
724 | ||
725 | def getbody(self): | |
726 | """Return body, either a string or a list of messages.""" | |
727 | if self.getmaintype() == 'multipart': | |
728 | return self.getbodyparts() | |
729 | else: | |
730 | return self.getbodytext() | |
731 | ||
732 | ||
733 | class SubMessage(Message): | |
734 | ||
735 | def __init__(self, f, n, fp): | |
736 | """Constructor.""" | |
737 | Message.__init__(self, f, n, fp) | |
738 | if self.getmaintype() == 'multipart': | |
739 | self.body = Message.getbodyparts(self) | |
740 | else: | |
741 | self.body = Message.getbodytext(self) | |
742 | self.bodyencoded = Message.getbodytext(self, decode=0) | |
743 | # XXX If this is big, should remember file pointers | |
744 | ||
745 | def __repr__(self): | |
746 | """String representation.""" | |
747 | f, n, fp = self.folder, self.number, self.fp | |
748 | return 'SubMessage(%s, %s, %s)' % (f, n, fp) | |
749 | ||
750 | def getbodytext(self, decode = 1): | |
751 | if not decode: | |
752 | return self.bodyencoded | |
753 | if type(self.body) == type(''): | |
754 | return self.body | |
755 | ||
756 | def getbodyparts(self): | |
757 | if type(self.body) == type([]): | |
758 | return self.body | |
759 | ||
760 | def getbody(self): | |
761 | return self.body | |
762 | ||
763 | ||
764 | class IntSet: | |
765 | """Class implementing sets of integers. | |
766 | ||
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. | |
771 | ||
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. | |
777 | ||
778 | The tostring() function yields a string that can be passed to another | |
779 | IntSet constructor; __repr__() is a valid IntSet constructor itself. | |
780 | """ | |
781 | ||
782 | # XXX The default begin/end separator means that negative numbers are | |
783 | # not supported very well. | |
784 | # | |
785 | # XXX There are currently no operations to remove set elements. | |
786 | ||
787 | def __init__(self, data = None, sep = ',', rng = '-'): | |
788 | self.pairs = [] | |
789 | self.sep = sep | |
790 | self.rng = rng | |
791 | if data: self.fromstring(data) | |
792 | ||
793 | def reset(self): | |
794 | self.pairs = [] | |
795 | ||
796 | def __cmp__(self, other): | |
797 | return cmp(self.pairs, other.pairs) | |
798 | ||
799 | def __hash__(self): | |
800 | return hash(self.pairs) | |
801 | ||
802 | def __repr__(self): | |
803 | return 'IntSet(%r, %r, %r)' % (self.tostring(), self.sep, self.rng) | |
804 | ||
805 | def normalize(self): | |
806 | self.pairs.sort() | |
807 | i = 1 | |
808 | while i < len(self.pairs): | |
809 | alo, ahi = self.pairs[i-1] | |
810 | blo, bhi = self.pairs[i] | |
811 | if ahi >= blo-1: | |
812 | self.pairs[i-1:i+1] = [(alo, max(ahi, bhi))] | |
813 | else: | |
814 | i = i+1 | |
815 | ||
816 | def tostring(self): | |
817 | s = '' | |
818 | for lo, hi in self.pairs: | |
819 | if lo == hi: t = repr(lo) | |
820 | else: t = repr(lo) + self.rng + repr(hi) | |
821 | if s: s = s + (self.sep + t) | |
822 | else: s = t | |
823 | return s | |
824 | ||
825 | def tolist(self): | |
826 | l = [] | |
827 | for lo, hi in self.pairs: | |
828 | m = range(lo, hi+1) | |
829 | l = l + m | |
830 | return l | |
831 | ||
832 | def fromlist(self, list): | |
833 | for i in list: | |
834 | self.append(i) | |
835 | ||
836 | def clone(self): | |
837 | new = IntSet() | |
838 | new.pairs = self.pairs[:] | |
839 | return new | |
840 | ||
841 | def min(self): | |
842 | return self.pairs[0][0] | |
843 | ||
844 | def max(self): | |
845 | return self.pairs[-1][-1] | |
846 | ||
847 | def contains(self, x): | |
848 | for lo, hi in self.pairs: | |
849 | if lo <= x <= hi: return True | |
850 | return False | |
851 | ||
852 | def append(self, x): | |
853 | for i in range(len(self.pairs)): | |
854 | lo, hi = self.pairs[i] | |
855 | if x < lo: # Need to insert before | |
856 | if x+1 == lo: | |
857 | self.pairs[i] = (x, hi) | |
858 | else: | |
859 | self.pairs.insert(i, (x, x)) | |
860 | if i > 0 and x-1 == self.pairs[i-1][1]: | |
861 | # Merge with previous | |
862 | self.pairs[i-1:i+1] = [ | |
863 | (self.pairs[i-1][0], | |
864 | self.pairs[i][1]) | |
865 | ] | |
866 | return | |
867 | if x <= hi: # Already in set | |
868 | return | |
869 | i = len(self.pairs) - 1 | |
870 | if i >= 0: | |
871 | lo, hi = self.pairs[i] | |
872 | if x-1 == hi: | |
873 | self.pairs[i] = lo, x | |
874 | return | |
875 | self.pairs.append((x, x)) | |
876 | ||
877 | def addpair(self, xlo, xhi): | |
878 | if xlo > xhi: return | |
879 | self.pairs.append((xlo, xhi)) | |
880 | self.normalize() | |
881 | ||
882 | def fromstring(self, data): | |
883 | new = [] | |
884 | for part in data.split(self.sep): | |
885 | list = [] | |
886 | for subp in part.split(self.rng): | |
887 | s = subp.strip() | |
888 | list.append(int(s)) | |
889 | if len(list) == 1: | |
890 | new.append((list[0], list[0])) | |
891 | elif len(list) == 2 and list[0] <= list[1]: | |
892 | new.append((list[0], list[1])) | |
893 | else: | |
894 | raise ValueError, 'bad data passed to IntSet' | |
895 | self.pairs = self.pairs + new | |
896 | self.normalize() | |
897 | ||
898 | ||
899 | # Subroutines to read/write entries in .mh_profile and .mh_sequences | |
900 | ||
901 | def pickline(file, key, casefold = 1): | |
902 | try: | |
903 | f = open(file, 'r') | |
904 | except IOError: | |
905 | return None | |
906 | pat = re.escape(key) + ':' | |
907 | prog = re.compile(pat, casefold and re.IGNORECASE) | |
908 | while 1: | |
909 | line = f.readline() | |
910 | if not line: break | |
911 | if prog.match(line): | |
912 | text = line[len(key)+1:] | |
913 | while 1: | |
914 | line = f.readline() | |
915 | if not line or not line[0].isspace(): | |
916 | break | |
917 | text = text + line | |
918 | return text.strip() | |
919 | return None | |
920 | ||
921 | def updateline(file, key, value, casefold = 1): | |
922 | try: | |
923 | f = open(file, 'r') | |
924 | lines = f.readlines() | |
925 | f.close() | |
926 | except IOError: | |
927 | lines = [] | |
928 | pat = re.escape(key) + ':(.*)\n' | |
929 | prog = re.compile(pat, casefold and re.IGNORECASE) | |
930 | if value is None: | |
931 | newline = None | |
932 | else: | |
933 | newline = '%s: %s\n' % (key, value) | |
934 | for i in range(len(lines)): | |
935 | line = lines[i] | |
936 | if prog.match(line): | |
937 | if newline is None: | |
938 | del lines[i] | |
939 | else: | |
940 | lines[i] = newline | |
941 | break | |
942 | else: | |
943 | if newline is not None: | |
944 | lines.append(newline) | |
945 | tempfile = file + "~" | |
946 | f = open(tempfile, 'w') | |
947 | for line in lines: | |
948 | f.write(line) | |
949 | f.close() | |
950 | os.rename(tempfile, file) | |
951 | ||
952 | ||
953 | # Test program | |
954 | ||
955 | def test(): | |
956 | global mh, f | |
957 | os.system('rm -rf $HOME/Mail/@test') | |
958 | mh = MH() | |
959 | def do(s): print s; print eval(s) | |
960 | do('mh.listfolders()') | |
961 | do('mh.listallfolders()') | |
962 | testfolders = ['@test', '@test/test1', '@test/test2', | |
963 | '@test/test1/test11', '@test/test1/test12', | |
964 | '@test/test1/test11/test111'] | |
965 | for t in testfolders: do('mh.makefolder(%r)' % (t,)) | |
966 | do('mh.listsubfolders(\'@test\')') | |
967 | do('mh.listallsubfolders(\'@test\')') | |
968 | f = mh.openfolder('@test') | |
969 | do('f.listsubfolders()') | |
970 | do('f.listallsubfolders()') | |
971 | do('f.getsequences()') | |
972 | seqs = f.getsequences() | |
973 | seqs['foo'] = IntSet('1-10 12-20', ' ').tolist() | |
974 | print seqs | |
975 | f.putsequences(seqs) | |
976 | do('f.getsequences()') | |
977 | for t in reversed(testfolders): do('mh.deletefolder(%r)' % (t,)) | |
978 | do('mh.getcontext()') | |
979 | context = mh.getcontext() | |
980 | f = mh.openfolder(context) | |
981 | do('f.getcurrent()') | |
982 | for seq in ['first', 'last', 'cur', '.', 'prev', 'next', | |
983 | 'first:3', 'last:3', 'cur:3', 'cur:-3', | |
984 | 'prev:3', 'next:3', | |
985 | '1:3', '1:-3', '100:3', '100:-3', '10000:3', '10000:-3', | |
986 | 'all']: | |
987 | try: | |
988 | do('f.parsesequence(%r)' % (seq,)) | |
989 | except Error, msg: | |
990 | print "Error:", msg | |
991 | stuff = os.popen("pick %r 2>/dev/null" % (seq,)).read() | |
992 | list = map(int, stuff.split()) | |
993 | print list, "<-- pick" | |
994 | do('f.listmessages()') | |
995 | ||
996 | ||
997 | if __name__ == '__main__': | |
998 | test() |