From 14178c4e59850d4340e116ce29764cbd469c33be Mon Sep 17 00:00:00 2001 From: Patrick Totzke Date: Sun, 5 Aug 2012 12:28:34 +0100 Subject: cleanup: split widgets.py and pep8/pyflakes fixes --- alot/account.py | 2 - alot/buffers.py | 24 +- alot/commands/__init__.py | 4 +- alot/commands/envelope.py | 20 +- alot/commands/globals.py | 39 ++- alot/commands/search.py | 6 +- alot/commands/thread.py | 31 +- alot/ui.py | 20 +- alot/widgets.py | 738 --------------------------------------------- alot/widgets/__init__.py | 0 alot/widgets/bufferlist.py | 29 ++ alot/widgets/globals.py | 203 +++++++++++++ alot/widgets/search.py | 186 ++++++++++++ alot/widgets/thread.py | 301 ++++++++++++++++++ alot/widgets/utils.py | 64 ++++ setup.py | 2 +- 16 files changed, 859 insertions(+), 810 deletions(-) delete mode 100644 alot/widgets.py create mode 100644 alot/widgets/__init__.py create mode 100644 alot/widgets/bufferlist.py create mode 100644 alot/widgets/globals.py create mode 100644 alot/widgets/search.py create mode 100644 alot/widgets/thread.py create mode 100644 alot/widgets/utils.py diff --git a/alot/account.py b/alot/account.py index 6bee5706..1686bff7 100644 --- a/alot/account.py +++ b/alot/account.py @@ -3,13 +3,11 @@ # For further details see the COPYING file import mailbox import logging -import time import os import glob from alot.helper import call_cmd_async from alot.helper import split_commandstring -import alot.crypto as crypto class SendingMailFailed(RuntimeError): diff --git a/alot/buffers.py b/alot/buffers.py index 65b33798..79851715 100644 --- a/alot/buffers.py +++ b/alot/buffers.py @@ -5,13 +5,19 @@ import urwid import os from notmuch import NotmuchError -import widgets from settings import settings import commands from walker import PipeWalker from helper import shorten_author_string from db.errors import NonexistantObjectError +from alot.widgets.globals import TagWidget +from alot.widgets.globals import HeadersList +from alot.widgets.globals import AttachmentWidget +from alot.widgets.bufferlist import BufferlineWidget +from alot.widgets.search import ThreadlineWidget +from alot.widgets.thread import MessageWidget + class Buffer(object): """Abstract base class for buffers.""" @@ -76,7 +82,7 @@ class BufferlistBuffer(Buffer): lines = list() displayedbuffers = filter(self.filtfun, self.ui.buffers) for (num, b) in enumerate(displayedbuffers): - line = widgets.BufferlineWidget(b) + line = BufferlineWidget(b) if (num % 2) == 0: attr = settings.get_theming_attribute('bufferlist', 'line_even') @@ -148,13 +154,13 @@ class EnvelopeBuffer(Buffer): key_att = settings.get_theming_attribute('envelope', 'header_key') value_att = settings.get_theming_attribute('envelope', 'header_value') - self.header_wgt = widgets.HeadersList(lines, key_att, value_att) + self.header_wgt = HeadersList(lines, key_att, value_att) displayed_widgets.append(self.header_wgt) #display attachments lines = [] for a in self.envelope.attachments: - lines.append(widgets.AttachmentWidget(a, selectable=False)) + lines.append(AttachmentWidget(a, selectable=False)) if lines: self.attachment_wgt = urwid.Pile(lines) displayed_widgets.append(self.attachment_wgt) @@ -232,7 +238,7 @@ class SearchBuffer(Buffer): self.body = self.listbox return - self.threadlist = PipeWalker(self.pipe, widgets.ThreadlineWidget, + self.threadlist = PipeWalker(self.pipe, ThreadlineWidget, dbman=self.dbman) self.listbox = urwid.ListBox(self.threadlist) @@ -318,9 +324,9 @@ class ThreadBuffer(Buffer): childcount[p] -= 1 bars.append(childcount[p] > 0) - mwidget = widgets.MessageWidget(m, even=(num % 2 == 0), - depth=depth, - bars_at=bars) + mwidget = MessageWidget(m, even=(num % 2 == 0), + depth=depth, + bars_at=bars) msglines.append(mwidget) self.body = urwid.ListBox(msglines) @@ -396,7 +402,7 @@ class TagListBuffer(Buffer): attr = settings.get_theming_attribute('taglist', 'line_odd') focus_att = settings.get_theming_attribute('taglist', 'line_focus') - tw = widgets.TagWidget(b, attr, focus_att) + tw = TagWidget(b, attr, focus_att) rows = [('fixed', tw.width(), tw)] if tw.hidden: rows.append(urwid.Text(b + ' [hidden]')) diff --git a/alot/commands/__init__.py b/alot/commands/__init__.py index 53cc4cfd..d3a052d0 100644 --- a/alot/commands/__init__.py +++ b/alot/commands/__init__.py @@ -196,9 +196,9 @@ def commandfactory(cmdline, mode='global'): # set pre and post command hooks get_hook = settings.get_hook parms['prehook'] = get_hook('pre_%s_%s' % (mode, cmdname)) or \ - get_hook('pre_global_%s' % cmdname) + get_hook('pre_global_%s' % cmdname) parms['posthook'] = get_hook('post_%s_%s' % (mode, cmdname)) or \ - get_hook('post_global_%s' % cmdname) + get_hook('post_global_%s' % cmdname) logging.debug('cmd parms %s' % parms) return cmdclass(**parms) diff --git a/alot/commands/envelope.py b/alot/commands/envelope.py index 89d3b5d8..b4c5604d 100644 --- a/alot/commands/envelope.py +++ b/alot/commands/envelope.py @@ -84,14 +84,14 @@ class SaveCommand(Command): # determine account to use sname, saddr = email.Utils.parseaddr(envelope.get('From')) account = settings.get_account_by_address(saddr) - if account == None: + if account is None: if not settings.get_accounts(): ui.notify('no accounts set.', priority='error') return else: account = settings.get_accounts()[0] - if account.draft_box == None: + if account.draft_box is None: ui.notify('abort: account <%s> has no draft_box set.' % saddr, priority='error') return @@ -130,7 +130,7 @@ class SendCommand(Command): # determine account to use for sending account = settings.get_account_by_address(saddr) - if account == None: + if account is None: if not settings.get_accounts(): ui.notify('no accounts set', priority='error') return @@ -185,8 +185,8 @@ class SendCommand(Command): (['--spawn'], {'action': BooleanAction, 'default':None, 'help':'spawn editor in new terminal'}), (['--refocus'], {'action': BooleanAction, 'default':True, - 'help':'refocus envelope after editing'}), - ]) + 'help':'refocus envelope after editing'}), +]) class EditCommand(Command): """edit mail""" def __init__(self, envelope=None, spawn=None, refocus=True, **kwargs): @@ -198,7 +198,7 @@ class EditCommand(Command): :param refocus: m """ self.envelope = envelope - self.openNew = (envelope != None) + self.openNew = (envelope is not None) self.force_spawn = spawn self.refocus = refocus self.edit_only_body = False @@ -280,15 +280,15 @@ class EditCommand(Command): if self.envelope.tmpfile: old_tmpfile = self.envelope.tmpfile self.envelope.tmpfile = tempfile.NamedTemporaryFile(delete=False, - prefix='alot.') + prefix='alot.') self.envelope.tmpfile.write(content.encode('utf-8')) self.envelope.tmpfile.flush() self.envelope.tmpfile.close() if old_tmpfile: os.unlink(old_tmpfile.name) cmd = globals.EditCommand(self.envelope.tmpfile.name, - on_success=openEnvelopeFromTmpfile, spawn=self.force_spawn, - thread=self.force_spawn, refocus=self.refocus) + on_success=openEnvelopeFromTmpfile, spawn=self.force_spawn, + thread=self.force_spawn, refocus=self.refocus) ui.apply_command(cmd) @@ -347,7 +347,7 @@ class ToggleHeaderCommand(Command): (['keyid'], {'nargs':argparse.REMAINDER, 'help':'which key id to use'})], help='mark mail to be signed before sending') @registerCommand(MODE, 'unsign', forced={'action': 'unsign'}, - help='mark mail not to be signed before sending') + help='mark mail not to be signed before sending') @registerCommand(MODE, 'togglesign', forced={'action': 'toggle'}, arguments=[ (['keyid'], {'nargs':argparse.REMAINDER, 'help':'which key id to use'})], help='toggle sign status') diff --git a/alot/commands/globals.py b/alot/commands/globals.py index d9bce6ff..13d876e4 100644 --- a/alot/commands/globals.py +++ b/alot/commands/globals.py @@ -18,16 +18,14 @@ from alot.completion import CommandLineCompleter from alot.commands import CommandParseError from alot.commands import commandfactory from alot import buffers -from alot import widgets +from alot.widgets.utils import DialogBox from alot import helper -from alot import crypto from alot.db.errors import DatabaseLockedError from alot.completion import ContactsCompleter from alot.completion import AccountCompleter from alot.db.envelope import Envelope from alot import commands from alot.settings import settings -from alot.errors import GPGProblem from alot.helper import split_commandstring from alot.utils.booleanaction import BooleanAction @@ -41,7 +39,7 @@ class ExitCommand(Command): def apply(self, ui): if settings.get('bug_on_exit'): if (yield ui.choice('realy quit?', select='yes', cancel='no', - msg_position='left')) == 'no': + msg_position='left')) == 'no': return for b in ui.buffers: b.cleanup() @@ -50,7 +48,7 @@ class ExitCommand(Command): @registerCommand(MODE, 'search', usage='search query', arguments=[ (['--sort'], {'help':'sort order', 'choices':[ - 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), + 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), (['query'], {'nargs':argparse.REMAINDER, 'help':'search string'})]) class SearchCommand(Command): """open a new search buffer""" @@ -218,7 +216,7 @@ class ExternalCommand(Command): def thread_code(*args): try: - if stdin == None: + if stdin is None: proc = subprocess.Popen(self.cmdlist, shell=self.shell, stderr=subprocess.PIPE) ret = proc.wait() @@ -290,7 +288,7 @@ class EditCommand(ExternalCommand): **kwargs) def apply(self, ui): - if self.cmdlist == None: + if self.cmdlist is None: ui.notify('no editor set', priority='error') else: return ExternalCommand.apply(self, ui) @@ -318,7 +316,6 @@ class CallCommand(Command): self.command = command def apply(self, ui): - hooks = settings.hooks try: exec self.command except Exception as e: @@ -346,7 +343,7 @@ class BufferCloseCommand(Command): @inlineCallbacks def apply(self, ui): - if self.buffer == None: + if self.buffer is None: self.buffer = ui.current_buffer if (isinstance(self.buffer, buffers.EnvelopeBuffer) and @@ -362,7 +359,7 @@ class BufferCloseCommand(Command): ui.apply_command(ExitCommand()) else: logging.info('not closing last remaining buffer as ' - 'global.quit_on_last_bclose is set to False') + 'global.quit_on_last_bclose is set to False') else: ui.buffer_close(self.buffer) @@ -494,7 +491,7 @@ class HelpCommand(Command): # mode specific maps if modemaps: linewidgets.append(urwid.Text((section_att, - '\n%s-mode specific maps' % ui.mode))) + '\n%s-mode specific maps' % ui.mode))) for (k, v) in modemaps.items(): line = urwid.Columns([('fixed', keycolumnwidth, urwid.Text((text_att, k))), @@ -514,9 +511,9 @@ class HelpCommand(Command): ckey = 'cancel' titletext = 'Bindings Help (%s cancels)' % ckey - box = widgets.DialogBox(body, titletext, - bodyattr=text_att, - titleattr=title_att) + box = DialogBox(body, titletext, + bodyattr=text_att, + titleattr=title_att) # put promptwidget as overlay on main widget overlay = urwid.Overlay(box, ui.mainframe, 'center', @@ -595,7 +592,7 @@ class ComposeCommand(Command): @inlineCallbacks def apply(self, ui): - if self.envelope == None: + if self.envelope is None: self.envelope = Envelope() if self.template is not None: #get location of tempsdir, containing msg templates @@ -687,7 +684,7 @@ class ComposeCommand(Command): ui.notify('could not locate signature: %s' % sig, priority='error') if (yield ui.choice('send without signature', - select='yes', cancel='no')) == 'no': + select='yes', cancel='no')) == 'no': return # Figure out whether we should GPG sign messages by default @@ -705,23 +702,23 @@ class ComposeCommand(Command): logging.debug(allbooks) if account is not None: abooks = settings.get_addressbooks(order=[account], - append_remaining=allbooks) + append_remaining=allbooks) logging.debug(abooks) completer = ContactsCompleter(abooks) else: completer = None to = yield ui.prompt('To', completer=completer) - if to == None: + if to is None: ui.notify('canceled') return self.envelope.add('To', to.strip(' \t\n,')) if settings.get('ask_subject') and \ - not 'Subject' in self.envelope.headers: + not 'Subject' in self.envelope.headers: subject = yield ui.prompt('Subject') logging.debug('SUBJECT: "%s"' % subject) - if subject == None: + if subject is None: ui.notify('canceled') return self.envelope.add('Subject', subject) @@ -733,7 +730,7 @@ class ComposeCommand(Command): logging.debug('attaching: ' + a) cmd = commands.envelope.EditCommand(envelope=self.envelope, - spawn=self.force_spawn, refocus=False) + spawn=self.force_spawn, refocus=False) ui.apply_command(cmd) diff --git a/alot/commands/search.py b/alot/commands/search.py index 22bca06b..51a3fdd6 100644 --- a/alot/commands/search.py +++ b/alot/commands/search.py @@ -39,11 +39,11 @@ class OpenThreadCommand(Command): @registerCommand(MODE, 'refine', help='refine query', arguments=[ (['--sort'], {'help':'sort order', 'choices':[ - 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), + 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), (['query'], {'nargs':argparse.REMAINDER, 'help':'search string'})]) @registerCommand(MODE, 'sort', help='set sort order', arguments=[ (['sort'], {'help':'sort order', 'choices':[ - 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), + 'oldest_first', 'newest_first', 'message_id', 'unsorted']}), ]) class RefineCommand(Command): """refine the querystring of this buffer""" @@ -186,7 +186,7 @@ class TagCommand(Command): thread.add_tags(tags, afterwards=refresh) if self.action == 'set': thread.add_tags(tags, afterwards=refresh, - remove_rest=True) + remove_rest=True) elif self.action == 'remove': thread.remove_tags(tags, afterwards=refresh) elif self.action == 'toggle': diff --git a/alot/commands/thread.py b/alot/commands/thread.py index f6b79595..079e2a10 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -5,7 +5,6 @@ import os import logging import tempfile from twisted.internet.defer import inlineCallbacks -import re import subprocess from email.Utils import parseaddr import mailcap @@ -15,8 +14,6 @@ from alot.commands import Command, registerCommand from alot.commands.globals import ExternalCommand from alot.commands.globals import FlushCommand from alot.commands.globals import ComposeCommand -from alot.commands.globals import RefreshCommand -from alot import widgets from alot import completion from alot.db.utils import decode_header from alot.db.utils import encode_header @@ -30,6 +27,9 @@ from alot.helper import parse_mailcap_nametemplate from alot.helper import split_commandstring from alot.utils.booleanaction import BooleanAction +from alot.widgets.globals import AttachmentWidget +from alot.widgets.thread import MessageSummaryWidget + MODE = 'thread' @@ -226,7 +226,8 @@ class ForwardCommand(Command): if qf: quote = qf(name, address, timestamp, ui=ui, dbm=ui.dbman) else: - quote = 'Forwarded message from %s (%s):\n' % (name or address, timestamp) + quote = 'Forwarded message from %s (%s):\n' % ( + name or address, timestamp) mailcontent = quote quotehook = settings.get_hook('text_quote') if quotehook: @@ -317,9 +318,9 @@ class EditNewCommand(Command): (['--all'], {'action': 'store_true', 'help':'affect all messages'})], help='display message source') @registerCommand(MODE, 'toggleheaders', forced={'all_headers': 'toggle'}, - arguments=[ - (['--all'], {'action': 'store_true', 'help':'affect all messages'})], - help='display all headers') + arguments=[ + (['--all'], {'action': 'store_true', 'help':'affect all messages'})], + help='display all headers') class ChangeDisplaymodeCommand(Command): """fold or unfold messages""" def __init__(self, all=False, visible=None, raw=None, all_headers=None, @@ -383,11 +384,11 @@ class ChangeDisplaymodeCommand(Command): (['--background'], {'action': 'store_true', 'help':'don\'t stop the interface'}), (['--add_tags'], {'action': 'store_true', - 'help':'add \'Tags\' header to the message'}), + 'help':'add \'Tags\' header to the message'}), (['--shell'], {'action': 'store_true', - 'help':'let the shell interpret the command'}), + 'help':'let the shell interpret the command'}), (['--notify_stdout'], {'action': 'store_true', - 'help':'display command\'s stdout as notification message'}), + 'help':'display command\'s stdout as notification message'}), ], ) class PipeCommand(Command): @@ -574,7 +575,7 @@ class RemoveCommand(Command): (['--separately'], {'action': 'store_true', 'help':'call print command once for each message'}), (['--add_tags'], {'action': 'store_true', - 'help':'add \'Tags\' header to the message'}), + 'help':'add \'Tags\' header to the message'}), ], ) class PrintCommand(PipeCommand): @@ -604,7 +605,7 @@ class PrintCommand(PipeCommand): # no print cmd set noop_msg = 'no print command specified. Set "print_cmd" in the '\ - 'global section.' + 'global section.' PipeCommand.__init__(self, [cmd], all=all, separately=separately, background=True, @@ -658,7 +659,7 @@ class SaveAttachmentCommand(Command): ui.notify('canceled') else: # save focussed attachment focus = ui.get_deep_focus() - if isinstance(focus, widgets.AttachmentWidget): + if isinstance(focus, AttachmentWidget): attachment = focus.get_attachment() filename = attachment.get_filename() if not self.path: @@ -747,9 +748,9 @@ class ThreadSelectCommand(Command): - if attachment line, this opens the attachment""" def apply(self, ui): focus = ui.get_deep_focus() - if isinstance(focus, widgets.MessageSummaryWidget): + if isinstance(focus, MessageSummaryWidget): ui.apply_command(ChangeDisplaymodeCommand(visible='toggle')) - elif isinstance(focus, widgets.AttachmentWidget): + elif isinstance(focus, AttachmentWidget): logging.info('open attachment') ui.apply_command(OpenAttachmentCommand(focus.get_attachment())) else: diff --git a/alot/ui.py b/alot/ui.py index ddb53798..0f07437f 100644 --- a/alot/ui.py +++ b/alot/ui.py @@ -11,7 +11,9 @@ import commands from commands import commandfactory from alot.commands import CommandParseError from alot.helper import string_decode -import widgets +from alot.widgets.utils import CatchKeyWidgetWrap +from alot.widgets.globals import CompleteEdit +from alot.widgets.globals import ChoiceWidget class InputWrap(urwid.WidgetWrap): @@ -92,9 +94,9 @@ class UI(object): self.mainframe_themed = urwid.AttrMap(self.mainframe, global_att) self.inputwrap = InputWrap(self, self.mainframe_themed) self.mainloop = urwid.MainLoop(self.inputwrap, - handle_mouse=False, - event_loop=urwid.TwistedEventLoop(), - unhandled_input=self.unhandeled_input) + handle_mouse=False, + event_loop=urwid.TwistedEventLoop(), + unhandled_input=self.unhandeled_input) self.mainloop.screen.set_terminal_properties(colors=colourmode) self.show_statusbar = settings.get('show_statusbar') @@ -123,8 +125,8 @@ class UI(object): logging.debug('called') afterwards() logging.debug('relay: %s' % relay_rest) - helpwrap = widgets.CatchKeyWidgetWrap(w, key, on_catch=oe, - relay_rest=relay_rest) + helpwrap = CatchKeyWidgetWrap(w, key, on_catch=oe, + relay_rest=relay_rest) self.inputwrap.set_root(helpwrap) self.inputwrap.select_cancel_only = not relay_rest @@ -158,7 +160,7 @@ class UI(object): #set up widgets leftpart = urwid.Text(prefix, align='left') - editpart = widgets.CompleteEdit(completer, on_exit=select_or_cancel, + editpart = CompleteEdit(completer, on_exit=select_or_cancel, edit_text=text, history=history) for i in range(tab): # hit some tabs @@ -314,8 +316,8 @@ class UI(object): #set up widgets msgpart = urwid.Text(message) - choicespart = widgets.ChoiceWidget(choices, callback=select_or_cancel, - select=select, cancel=cancel) + choicespart = ChoiceWidget(choices, callback=select_or_cancel, + select=select, cancel=cancel) # build widget if msg_position == 'left': diff --git a/alot/widgets.py b/alot/widgets.py deleted file mode 100644 index 16d4b53b..00000000 --- a/alot/widgets.py +++ /dev/null @@ -1,738 +0,0 @@ -# Copyright (C) 2011-2012 Patrick Totzke -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file -import urwid -import logging - -from settings import settings -from alot.helper import shorten_author_string -from alot.helper import tag_cmp -from alot.helper import string_decode -import alot.db.message as message -from alot.db.attachment import Attachment -from alot.db.utils import decode_header - - -class AttrFlipWidget(urwid.AttrMap): - """ - An AttrMap that can remember attributes to set - """ - def __init__(self, w, maps, init_map='normal'): - self.maps = maps - urwid.AttrMap.__init__(self, w, maps[init_map]) - - def set_map(self, attrstring): - self.set_attr_map({None: self.maps[attrstring]}) - - -class DialogBox(urwid.WidgetWrap): - def __init__(self, body, title, bodyattr=None, titleattr=None): - self.body = urwid.LineBox(body) - self.title = urwid.Text(title) - if titleattr is not None: - self.title = urwid.AttrMap(self.title, titleattr) - if bodyattr is not None: - self.body = urwid.AttrMap(self.body, bodyattr) - - box = urwid.Overlay(self.title, self.body, - align='center', - valign='top', - width=len(title), - height=None, - ) - urwid.WidgetWrap.__init__(self, box) - - def selectable(self): - return self.body.selectable() - - def keypress(self, size, key): - return self.body.keypress(size, key) - - -class CatchKeyWidgetWrap(urwid.WidgetWrap): - def __init__(self, widget, key, on_catch, relay_rest=True): - urwid.WidgetWrap.__init__(self, widget) - self.key = key - self.relay = relay_rest - self.on_catch = on_catch - - def selectable(self): - return True - - def keypress(self, size, key): - logging.debug('CATCH KEY: %s' % key) - logging.debug('relay: %s' % self.relay) - if key == self.key: - self.on_catch() - elif self._w.selectable() and self.relay: - return self._w.keypress(size, key) - - -class ThreadlineWidget(urwid.AttrMap): - """ - selectable line widget that represents a :class:`~alot.db.Thread` - in the :class:`~alot.buffers.SearchBuffer`. - """ - def __init__(self, tid, dbman): - self.dbman = dbman - self.thread = dbman.get_thread(tid) - self.tag_widgets = [] - self.display_content = settings.get('display_content_in_threadline') - self.structure = None - self.rebuild() - normal = self.structure['normal'] - focussed = self.structure['focus'] - urwid.AttrMap.__init__(self, self.columns, normal, focussed) - - def _build_part(self, name, struct, minw, maxw, align): - def pad(string, shorten=None): - if maxw: - if len(string) > maxw: - if shorten: - string = shorten(string, maxw) - else: - string = string[:maxw] - if minw: - if len(string) < minw: - if align == 'left': - string = string.ljust(minw) - elif align == 'center': - string = string.center(minw) - else: - string = string.rjust(minw) - return string - - part = None - width = None - if name == 'date': - newest = None - datestring = '' - if self.thread: - newest = self.thread.get_newest_date() - datestring = settings.represent_datetime(newest) - datestring = pad(datestring) - width = len(datestring) - part = AttrFlipWidget(urwid.Text(datestring), struct['date']) - - elif name == 'mailcount': - if self.thread: - mailcountstring = "(%d)" % self.thread.get_total_messages() - else: - mailcountstring = "(?)" - datestring = pad(mailcountstring) - width = len(mailcountstring) - mailcount_w = AttrFlipWidget(urwid.Text(mailcountstring), - struct['mailcount']) - part = mailcount_w - elif name == 'authors': - if self.thread: - authors = self.thread.get_authors_string() or '(None)' - else: - authors = '(None)' - authorsstring = pad(authors, shorten_author_string) - authors_w = AttrFlipWidget(urwid.Text(authorsstring), - struct['authors']) - width = len(authorsstring) - part = authors_w - - elif name == 'subject': - if self.thread: - subjectstring = self.thread.get_subject() or ' ' - else: - subjectstring = ' ' - # sanitize subject string: - subjectstring = subjectstring.replace('\n', ' ') - subjectstring = subjectstring.replace('\r', '') - subjectstring = pad(subjectstring) - - subject_w = AttrFlipWidget(urwid.Text(subjectstring, wrap='clip'), - struct['subject']) - if subjectstring: - width = len(subjectstring) - part = subject_w - - elif name == 'content': - if self.thread: - msgs = self.thread.get_messages().keys() - else: - msgs = [] - # sort the most recent messages first - msgs.sort(key=lambda msg: msg.get_date(), reverse=True) - lastcontent = ' '.join([m.get_text_content() for m in msgs]) - contentstring = pad(lastcontent.replace('\n', ' ').strip()) - content_w = AttrFlipWidget(urwid.Text( - contentstring, - wrap='clip'), - struct['content']) - width = len(contentstring) - part = content_w - elif name == 'tags': - if self.thread: - fallback_normal = struct[name]['normal'] - fallback_focus = struct[name]['focus'] - tag_widgets = [TagWidget(t, fallback_normal, fallback_focus) - for t in self.thread.get_tags()] - tag_widgets.sort(tag_cmp, - lambda tag_widget: tag_widget.translated) - else: - tag_widgets = [] - cols = [] - length = -1 - for tag_widget in tag_widgets: - if not tag_widget.hidden: - wrapped_tagwidget = tag_widget - tag_width = tag_widget.width() - cols.append(('fixed', tag_width, wrapped_tagwidget)) - length += tag_width + 1 - if cols: - part = urwid.Columns(cols, dividechars=1) - width = length - return width, part - - def rebuild(self): - self.widgets = [] - columns = [] - self.structure = settings.get_threadline_theming(self.thread) - for partname in self.structure['parts']: - minw = maxw = None - width_tuple = self.structure[partname]['width'] - if width_tuple is not None: - if width_tuple[0] == 'fit': - minw, maxw = width_tuple[1:] - align_mode = self.structure[partname]['alignment'] - width, part = self._build_part(partname, self.structure, - minw, maxw, align_mode) - if part is not None: - if isinstance(part, urwid.Columns): - for w in part.widget_list: - self.widgets.append(w) - else: - self.widgets.append(part) - - # compute width and align - if width_tuple[0] == 'weight': - columnentry = width_tuple + (part,) - else: - columnentry = ('fixed', width, part) - columns.append(columnentry) - self.columns = urwid.Columns(columns, dividechars=1) - self.original_widget = self.columns - - def render(self, size, focus=False): - for w in self.widgets: - w.set_map('focus' if focus else 'normal') - return urwid.AttrMap.render(self, size, focus) - - def selectable(self): - return True - - def keypress(self, size, key): - return key - - def get_thread(self): - return self.thread - - def _get_theme(self, component, focus=False): - path = ['search', 'threadline', component] - if focus: - path.append('focus') - else: - path.append('normal') - return settings.get_theming_attribute(path) - - -class BufferlineWidget(urwid.Text): - """ - selectable text widget that represents a :class:`~alot.buffers.Buffer` - in the :class:`~alot.buffers.BufferlistBuffer`. - """ - - def __init__(self, buffer): - self.buffer = buffer - line = buffer.__str__() - urwid.Text.__init__(self, line, wrap='clip') - - def selectable(self): - return True - - def keypress(self, size, key): - return key - - def get_buffer(self): - return self.buffer - - -class TagWidget(urwid.AttrMap): - """ - text widget that renders a tagstring. - - It looks up the string it displays in the `tags` section - of the config as well as custom theme settings for its tag. - """ - def __init__(self, tag, fallback_normal=None, fallback_focus=None): - self.tag = tag - representation = settings.get_tagstring_representation(tag, - fallback_normal, - fallback_focus) - self.translated = representation['translated'] - self.hidden = self.translated == '' - self.txt = urwid.Text(self.translated, wrap='clip') - normal_att = representation['normal'] - focus_att = representation['focussed'] - self.attmaps = {'normal': normal_att, 'focus': focus_att} - urwid.AttrMap.__init__(self, self.txt, normal_att, focus_att) - - def set_map(self, attrstring): - self.set_attr_map({None: self.attmaps[attrstring]}) - - def width(self): - # evil voodoo hotfix for double width chars that may - # lead e.g. to strings with length 1 that need width 2 - return self.txt.pack()[0] - - def selectable(self): - return True - - def keypress(self, size, key): - return key - - def get_tag(self): - return self.tag - - def set_focussed(self): - self.set_attr_map(self.attmap['focus']) - - def set_unfocussed(self): - self.set_attr_map(self.attmap['normal']) - - -class ChoiceWidget(urwid.Text): - def __init__(self, choices, callback, cancel=None, select=None): - self.choices = choices - self.callback = callback - self.cancel = cancel - self.select = select - - items = [] - for k, v in choices.items(): - if v == select and select != None: - items.append('[%s]:%s' % (k, v)) - else: - items.append('(%s):%s' % (k, v)) - urwid.Text.__init__(self, ' '.join(items)) - - def selectable(self): - return True - - def keypress(self, size, key): - if key == 'select' and self.select != None: - self.callback(self.select) - elif key == 'cancel' and self.cancel != None: - self.callback(self.cancel) - elif key in self.choices: - self.callback(self.choices[key]) - else: - return key - - -class CompleteEdit(urwid.Edit): - def __init__(self, completer, on_exit, edit_text=u'', - history=None, **kwargs): - self.completer = completer - self.on_exit = on_exit - self.history = list(history) # we temporarily add stuff here - self.historypos = None - - if not isinstance(edit_text, unicode): - edit_text = string_decode(edit_text) - self.start_completion_pos = len(edit_text) - self.completions = None - urwid.Edit.__init__(self, edit_text=edit_text, **kwargs) - - def keypress(self, size, key): - # if we tabcomplete - if key in ['tab', 'shift tab'] and self.completer: - # if not already in completion mode - if not self.completions: - self.completions = [(self.edit_text, self.edit_pos)] + \ - self.completer.complete(self.edit_text, self.edit_pos) - self.focus_in_clist = 1 - else: # otherwise tab through results - if key == 'tab': - self.focus_in_clist += 1 - else: - self.focus_in_clist -= 1 - if len(self.completions) > 1: - ctext, cpos = self.completions[self.focus_in_clist % - len(self.completions)] - self.set_edit_text(ctext) - self.set_edit_pos(cpos) - else: - self.edit_pos += 1 - if self.edit_pos >= len(self.edit_text): - self.edit_text += ' ' - self.completions = None - elif key in ['up', 'down']: - if self.history: - if self.historypos == None: - self.history.append(self.edit_text) - self.historypos = len(self.history) - 1 - if key == 'cursor up': - self.historypos = (self.historypos + 1) % len(self.history) - else: - self.historypos = (self.historypos - 1) % len(self.history) - self.set_edit_text(self.history[self.historypos]) - elif key == 'select': - self.on_exit(self.edit_text) - elif key == 'cancel': - self.on_exit(None) - elif key == 'ctrl a': - self.set_edit_pos(0) - elif key == 'ctrl e': - self.set_edit_pos(len(self.edit_text)) - else: - result = urwid.Edit.keypress(self, size, key) - self.completions = None - return result - - -class MessageWidget(urwid.WidgetWrap): - """ - Flow widget that renders a :class:`~alot.db.message.Message`. - """ - #TODO: atm this is heavily bent to work nicely with ThreadBuffer to display - #a tree structure. A better way would be to keep this widget simple - #(subclass urwid.Pile) and use urwids new Tree widgets - def __init__(self, message, even=False, folded=True, raw=False, - all_headers=False, depth=0, bars_at=[]): - """ - :param message: the message to display - :type message: alot.db.Message - :param even: use messagesummary_even theme for summary - :type even: bool - :param folded: fold message initially - :type folded: bool - :param raw: show message source initially - :type raw: bool - :param all_headers: show all headers initially - :type all_headers: bool - :param depth: number of characters to shift content to the right - :type depth: int - :param bars_at: defines for each column of the indentation whether to - use a vertical bar instead of a space. - :type bars_at: list(bool) - """ - self.message = message - self.mail = self.message.get_email() - - self.depth = depth - self.bars_at = bars_at - self.even = even - self.folded = folded - self.show_raw = raw - self.show_all_headers = all_headers - - # define subwidgets that will be created on demand - self.sumline = None - self.headerw = None - self.attachmentw = None - self.bodyw = None - self.sourcew = None - - # set available and to be displayed headers - self._all_headers = list(set(self.mail.keys())) - displayed = settings.get('displayed_headers') - self._filtered_headers = [k for k in displayed if k in self.mail] - self._displayed_headers = None - - bars = settings.get_theming_attribute('thread', 'arrow_bars') - self.arrow_bars_att = bars - heads = settings.get_theming_attribute('thread', 'arrow_heads') - self.arrow_heads_att = heads - logging.debug(self.arrow_heads_att) - - self.rebuild() # this will build self.pile - urwid.WidgetWrap.__init__(self, self.pile) - - def get_focus(self): - return self.pile.get_focus() - - def rebuild(self): - self.sumline = self._build_sum_line() - if not self.folded: # only if already unfolded - self.displayed_list = [self.sumline] - if self.show_raw: - srcw = self._get_source_widget() - self.displayed_list.append(srcw) - else: - hw = self._get_header_widget() - aw = self._get_attachment_widget() - bw = self._get_body_widget() - if hw: - self.displayed_list.append(hw) - if aw: - self.displayed_list.append(aw) - self.displayed_list.append(bw) - else: - self.displayed_list = [self.sumline] - self.pile = urwid.Pile(self.displayed_list) - self._w = self.pile - - def _build_sum_line(self): - """creates/returns the widget that displays the summary line.""" - self.sumw = MessageSummaryWidget(self.message, even=self.even) - cols = [] - bc = list() # box_columns - if self.depth > 1: - bc.append(0) - spacer = self._get_spacer(self.bars_at[1:-1]) - cols.append(spacer) - if self.depth > 0: - if self.bars_at[-1]: - arrowhead = [(self.arrow_bars_att, u'\u251c'), - (self.arrow_heads_att, u'\u25b6')] - else: - arrowhead = [(self.arrow_bars_att, u'\u2514'), - (self.arrow_heads_att, u'\u25b6')] - cols.append(('fixed', 2, urwid.Text(arrowhead))) - cols.append(self.sumw) - line = urwid.Columns(cols, box_columns=bc) - return line - - def _get_header_widget(self): - """creates/returns the widget that displays the mail header""" - all_shown = (self._all_headers == self._displayed_headers) - - if self.headerw and (self.show_all_headers == all_shown): - return self.headerw - - if self.show_all_headers: - self._displayed_headers = self._all_headers - else: - self._displayed_headers = self._filtered_headers - - mail = self.message.get_email() - # normalize values if only filtered list is shown - norm = not (self._displayed_headers == self._all_headers) - - #build lines - lines = [] - for key in self._displayed_headers: - if key in mail: - if key.lower() in ['cc', 'bcc', 'to']: - values = mail.get_all(key) - values = [decode_header(v, normalize=norm) for v in values] - lines.append((key, ', '.join(values))) - else: - for value in mail.get_all(key): - dvalue = decode_header(value, normalize=norm) - lines.append((key, dvalue)) - - key_att = settings.get_theming_attribute('thread', 'header_key') - value_att = settings.get_theming_attribute('thread', 'header_value') - cols = [HeadersList(lines, key_att, value_att)] - bc = list() - if self.depth: - cols.insert(0, self._get_spacer(self.bars_at[1:])) - bc.append(0) - cols.insert(1, self._get_arrowhead_aligner()) - bc.append(1) - self.headerw = urwid.Columns(cols, box_columns=bc) - return self.headerw - - def _get_attachment_widget(self): - if self.message.get_attachments() and not self.attachmentw: - lines = [] - for a in self.message.get_attachments(): - cols = [AttachmentWidget(a)] - bc = list() - if self.depth: - cols.insert(0, self._get_spacer(self.bars_at[1:])) - bc.append(0) - cols.insert(1, self._get_arrowhead_aligner()) - bc.append(1) - lines.append(urwid.Columns(cols, box_columns=bc)) - self.attachmentw = urwid.Pile(lines) - return self.attachmentw - - def _get_body_widget(self): - """creates/returns the widget that displays the mail body""" - if not self.bodyw: - cols = [MessageBodyWidget(self.message.get_email())] - bc = list() - if self.depth: - cols.insert(0, self._get_spacer(self.bars_at[1:])) - bc.append(0) - cols.insert(1, self._get_arrowhead_aligner()) - bc.append(1) - self.bodyw = urwid.Columns(cols, box_columns=bc) - return self.bodyw - - def _get_source_widget(self): - """creates/returns the widget that displays the mail body""" - if not self.sourcew: - cols = [urwid.Text(self.message.get_email().as_string())] - bc = list() - if self.depth: - cols.insert(0, self._get_spacer(self.bars_at[1:])) - bc.append(0) - cols.insert(1, self._get_arrowhead_aligner()) - bc.append(1) - self.sourcew = urwid.Columns(cols, box_columns=bc) - return self.sourcew - - def _get_spacer(self, bars_at): - prefixchars = [] - length = len(bars_at) - for b in bars_at: - if b: - c = u'\u2502' - else: - c = ' ' - prefixchars.append(('fixed', 1, urwid.SolidFill(c))) - - spacer = urwid.Columns(prefixchars, box_columns=range(length)) - spacer = urwid.AttrMap(spacer, self.arrow_bars_att) - return ('fixed', length, spacer) - - def _get_arrowhead_aligner(self): - if self.message.has_replies(): - aligner = u'\u2502' - else: - aligner = ' ' - aligner = urwid.SolidFill(aligner) - return ('fixed', 1, urwid.AttrMap(aligner, self.arrow_bars_att)) - - def selectable(self): - return True - - def keypress(self, size, key): - return self.pile.keypress(size, key) - - def get_message(self): - """get contained :class`~alot.db.message.Message`""" - return self.message - - def get_email(self): - """get contained :class:`email `""" - return self.message.get_email() - - -class MessageSummaryWidget(urwid.WidgetWrap): - """ - one line summary of a :class:`~alot.db.message.Message`. - """ - - def __init__(self, message, even=True): - """ - :param message: a message - :type message: alot.db.Message - :param even: even entry in a pile of messages? Used for theming. - :type even: bool - """ - self.message = message - self.even = even - if even: - attr = settings.get_theming_attribute('thread', 'summary', 'even') - else: - attr = settings.get_theming_attribute('thread', 'summary', 'odd') - focus_att = settings.get_theming_attribute('thread', 'summary', - 'focus') - cols = [] - - sumstr = self.__str__() - txt = urwid.Text(sumstr) - cols.append(txt) - - thread_tags = message.get_thread().get_tags(intersection=True) - outstanding_tags = set(message.get_tags()).difference(thread_tags) - tag_widgets = [TagWidget(t, attr, focus_att) for t in outstanding_tags] - tag_widgets.sort(tag_cmp, lambda tag_widget: tag_widget.translated) - for tag_widget in tag_widgets: - if not tag_widget.hidden: - cols.append(('fixed', tag_widget.width(), tag_widget)) - line = urwid.AttrMap(urwid.Columns(cols, dividechars=1), attr, - focus_att) - urwid.WidgetWrap.__init__(self, line) - - def __str__(self): - author, address = self.message.get_author() - date = self.message.get_datestring() - rep = author if author != '' else address - if date != None: - rep += " (%s)" % date - return rep - - def selectable(self): - return True - - def keypress(self, size, key): - return key - - -class HeadersList(urwid.WidgetWrap): - """ renders a pile of header values as key/value list """ - def __init__(self, headerslist, key_attr, value_attr): - self.headers = headerslist - self.key_attr = key_attr - self.value_attr = value_attr - pile = urwid.Pile(self._build_lines(headerslist)) - att = settings.get_theming_attribute('thread', 'header') - pile = urwid.AttrMap(pile, att) - urwid.WidgetWrap.__init__(self, pile) - - def __str__(self): - return str(self.headers) - - def _build_lines(self, lines): - max_key_len = 1 - headerlines = [] - #calc max length of key-string - for key, value in lines: - if len(key) > max_key_len: - max_key_len = len(key) - for key, value in lines: - ##todo : even/odd - keyw = ('fixed', max_key_len + 1, - urwid.Text((self.key_attr, key))) - valuew = urwid.Text((self.value_attr, value)) - line = urwid.Columns([keyw, valuew]) - headerlines.append(line) - return headerlines - - -class MessageBodyWidget(urwid.AttrMap): - """ - displays printable parts of an email - """ - - def __init__(self, msg): - bodytxt = message.extract_body(msg) - att = settings.get_theming_attribute('thread', 'body') - urwid.AttrMap.__init__(self, urwid.Text(bodytxt), att) - - -class AttachmentWidget(urwid.WidgetWrap): - """ - one-line summary of an :class:`~alot.db.attachment.Attachment`. - """ - def __init__(self, attachment, selectable=True): - self._selectable = selectable - self.attachment = attachment - if not isinstance(attachment, Attachment): - self.attachment = Attachment(self.attachment) - att = settings.get_theming_attribute('thread', 'attachment') - focus_att = settings.get_theming_attribute('thread', - 'attachment_focus') - widget = urwid.AttrMap(urwid.Text(self.attachment.__str__()), - att, focus_att) - urwid.WidgetWrap.__init__(self, widget) - - def get_attachment(self): - return self.attachment - - def selectable(self): - return self._selectable - - def keypress(self, size, key): - return key diff --git a/alot/widgets/__init__.py b/alot/widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/alot/widgets/bufferlist.py b/alot/widgets/bufferlist.py new file mode 100644 index 00000000..0ab315c5 --- /dev/null +++ b/alot/widgets/bufferlist.py @@ -0,0 +1,29 @@ +# Copyright (C) 2011-2012 Patrick Totzke +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +""" +Widgets specific to Bufferlist mode +""" +import urwid + + +class BufferlineWidget(urwid.Text): + """ + selectable text widget that represents a :class:`~alot.buffers.Buffer` + in the :class:`~alot.buffers.BufferlistBuffer`. + """ + + def __init__(self, buffer): + self.buffer = buffer + line = buffer.__str__() + urwid.Text.__init__(self, line, wrap='clip') + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + def get_buffer(self): + return self.buffer diff --git a/alot/widgets/globals.py b/alot/widgets/globals.py new file mode 100644 index 00000000..96fec312 --- /dev/null +++ b/alot/widgets/globals.py @@ -0,0 +1,203 @@ +# Copyright (C) 2011-2012 Patrick Totzke +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +""" +This contains alot-specific :ref:`urwid.Widgets` used in more than one mode. +""" +import urwid + +from alot.helper import string_decode +from alot.settings import settings +from alot.db.attachment import Attachment + + +class AttachmentWidget(urwid.WidgetWrap): + """ + one-line summary of an :class:`~alot.db.attachment.Attachment`. + """ + def __init__(self, attachment, selectable=True): + self._selectable = selectable + self.attachment = attachment + if not isinstance(attachment, Attachment): + self.attachment = Attachment(self.attachment) + att = settings.get_theming_attribute('thread', 'attachment') + focus_att = settings.get_theming_attribute('thread', + 'attachment_focus') + widget = urwid.AttrMap(urwid.Text(self.attachment.__str__()), + att, focus_att) + urwid.WidgetWrap.__init__(self, widget) + + def get_attachment(self): + return self.attachment + + def selectable(self): + return self._selectable + + def keypress(self, size, key): + return key + + +class ChoiceWidget(urwid.Text): + def __init__(self, choices, callback, cancel=None, select=None): + self.choices = choices + self.callback = callback + self.cancel = cancel + self.select = select + + items = [] + for k, v in choices.items(): + if v == select and select is not None: + items.append('[%s]:%s' % (k, v)) + else: + items.append('(%s):%s' % (k, v)) + urwid.Text.__init__(self, ' '.join(items)) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == 'select' and self.select is not None: + self.callback(self.select) + elif key == 'cancel' and self.cancel is not None: + self.callback(self.cancel) + elif key in self.choices: + self.callback(self.choices[key]) + else: + return key + + +class CompleteEdit(urwid.Edit): + def __init__(self, completer, on_exit, edit_text=u'', + history=None, **kwargs): + self.completer = completer + self.on_exit = on_exit + self.history = list(history) # we temporarily add stuff here + self.historypos = None + + if not isinstance(edit_text, unicode): + edit_text = string_decode(edit_text) + self.start_completion_pos = len(edit_text) + self.completions = None + urwid.Edit.__init__(self, edit_text=edit_text, **kwargs) + + def keypress(self, size, key): + # if we tabcomplete + if key in ['tab', 'shift tab'] and self.completer: + # if not already in completion mode + if not self.completions: + self.completions = [(self.edit_text, self.edit_pos)] + \ + self.completer.complete(self.edit_text, self.edit_pos) + self.focus_in_clist = 1 + else: # otherwise tab through results + if key == 'tab': + self.focus_in_clist += 1 + else: + self.focus_in_clist -= 1 + if len(self.completions) > 1: + ctext, cpos = self.completions[self.focus_in_clist % + len(self.completions)] + self.set_edit_text(ctext) + self.set_edit_pos(cpos) + else: + self.edit_pos += 1 + if self.edit_pos >= len(self.edit_text): + self.edit_text += ' ' + self.completions = None + elif key in ['up', 'down']: + if self.history: + if self.historypos is None: + self.history.append(self.edit_text) + self.historypos = len(self.history) - 1 + if key == 'cursor up': + self.historypos = (self.historypos + 1) % len(self.history) + else: + self.historypos = (self.historypos - 1) % len(self.history) + self.set_edit_text(self.history[self.historypos]) + elif key == 'select': + self.on_exit(self.edit_text) + elif key == 'cancel': + self.on_exit(None) + elif key == 'ctrl a': + self.set_edit_pos(0) + elif key == 'ctrl e': + self.set_edit_pos(len(self.edit_text)) + else: + result = urwid.Edit.keypress(self, size, key) + self.completions = None + return result + + +class HeadersList(urwid.WidgetWrap): + """ renders a pile of header values as key/value list """ + def __init__(self, headerslist, key_attr, value_attr): + self.headers = headerslist + self.key_attr = key_attr + self.value_attr = value_attr + pile = urwid.Pile(self._build_lines(headerslist)) + att = settings.get_theming_attribute('thread', 'header') + pile = urwid.AttrMap(pile, att) + urwid.WidgetWrap.__init__(self, pile) + + def __str__(self): + return str(self.headers) + + def _build_lines(self, lines): + max_key_len = 1 + headerlines = [] + #calc max length of key-string + for key, value in lines: + if len(key) > max_key_len: + max_key_len = len(key) + for key, value in lines: + ##todo : even/odd + keyw = ('fixed', max_key_len + 1, + urwid.Text((self.key_attr, key))) + valuew = urwid.Text((self.value_attr, value)) + line = urwid.Columns([keyw, valuew]) + headerlines.append(line) + return headerlines + + +class TagWidget(urwid.AttrMap): + """ + text widget that renders a tagstring. + + It looks up the string it displays in the `tags` section + of the config as well as custom theme settings for its tag. + """ + def __init__(self, tag, fallback_normal=None, fallback_focus=None): + self.tag = tag + representation = settings.get_tagstring_representation(tag, + fallback_normal, + fallback_focus) + self.translated = representation['translated'] + self.hidden = self.translated == '' + self.txt = urwid.Text(self.translated, wrap='clip') + normal_att = representation['normal'] + focus_att = representation['focussed'] + self.attmaps = {'normal': normal_att, 'focus': focus_att} + urwid.AttrMap.__init__(self, self.txt, normal_att, focus_att) + + def set_map(self, attrstring): + self.set_attr_map({None: self.attmaps[attrstring]}) + + def width(self): + # evil voodoo hotfix for double width chars that may + # lead e.g. to strings with length 1 that need width 2 + return self.txt.pack()[0] + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + def get_tag(self): + return self.tag + + def set_focussed(self): + self.set_attr_map(self.attmap['focus']) + + def set_unfocussed(self): + self.set_attr_map(self.attmap['normal']) diff --git a/alot/widgets/search.py b/alot/widgets/search.py new file mode 100644 index 00000000..6f8ed126 --- /dev/null +++ b/alot/widgets/search.py @@ -0,0 +1,186 @@ +# Copyright (C) 2011-2012 Patrick Totzke +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +""" +Widgets specific to search mode +""" +import urwid + +from alot.settings import settings +from alot.helper import shorten_author_string +from alot.helper import tag_cmp +from alot.widgets.utils import AttrFlipWidget +from alot.widgets.globals import TagWidget + + +class ThreadlineWidget(urwid.AttrMap): + """ + selectable line widget that represents a :class:`~alot.db.Thread` + in the :class:`~alot.buffers.SearchBuffer`. + """ + def __init__(self, tid, dbman): + self.dbman = dbman + self.thread = dbman.get_thread(tid) + self.tag_widgets = [] + self.display_content = settings.get('display_content_in_threadline') + self.structure = None + self.rebuild() + normal = self.structure['normal'] + focussed = self.structure['focus'] + urwid.AttrMap.__init__(self, self.columns, normal, focussed) + + def _build_part(self, name, struct, minw, maxw, align): + def pad(string, shorten=None): + if maxw: + if len(string) > maxw: + if shorten: + string = shorten(string, maxw) + else: + string = string[:maxw] + if minw: + if len(string) < minw: + if align == 'left': + string = string.ljust(minw) + elif align == 'center': + string = string.center(minw) + else: + string = string.rjust(minw) + return string + + part = None + width = None + if name == 'date': + newest = None + datestring = '' + if self.thread: + newest = self.thread.get_newest_date() + datestring = settings.represent_datetime(newest) + datestring = pad(datestring) + width = len(datestring) + part = AttrFlipWidget(urwid.Text(datestring), struct['date']) + + elif name == 'mailcount': + if self.thread: + mailcountstring = "(%d)" % self.thread.get_total_messages() + else: + mailcountstring = "(?)" + datestring = pad(mailcountstring) + width = len(mailcountstring) + mailcount_w = AttrFlipWidget(urwid.Text(mailcountstring), + struct['mailcount']) + part = mailcount_w + elif name == 'authors': + if self.thread: + authors = self.thread.get_authors_string() or '(None)' + else: + authors = '(None)' + authorsstring = pad(authors, shorten_author_string) + authors_w = AttrFlipWidget(urwid.Text(authorsstring), + struct['authors']) + width = len(authorsstring) + part = authors_w + + elif name == 'subject': + if self.thread: + subjectstring = self.thread.get_subject() or ' ' + else: + subjectstring = ' ' + # sanitize subject string: + subjectstring = subjectstring.replace('\n', ' ') + subjectstring = subjectstring.replace('\r', '') + subjectstring = pad(subjectstring) + + subject_w = AttrFlipWidget(urwid.Text(subjectstring, wrap='clip'), + struct['subject']) + if subjectstring: + width = len(subjectstring) + part = subject_w + + elif name == 'content': + if self.thread: + msgs = self.thread.get_messages().keys() + else: + msgs = [] + # sort the most recent messages first + msgs.sort(key=lambda msg: msg.get_date(), reverse=True) + lastcontent = ' '.join([m.get_text_content() for m in msgs]) + contentstring = pad(lastcontent.replace('\n', ' ').strip()) + content_w = AttrFlipWidget(urwid.Text( + contentstring, + wrap='clip'), + struct['content']) + width = len(contentstring) + part = content_w + elif name == 'tags': + if self.thread: + fallback_normal = struct[name]['normal'] + fallback_focus = struct[name]['focus'] + tag_widgets = [TagWidget(t, fallback_normal, fallback_focus) + for t in self.thread.get_tags()] + tag_widgets.sort(tag_cmp, + lambda tag_widget: tag_widget.translated) + else: + tag_widgets = [] + cols = [] + length = -1 + for tag_widget in tag_widgets: + if not tag_widget.hidden: + wrapped_tagwidget = tag_widget + tag_width = tag_widget.width() + cols.append(('fixed', tag_width, wrapped_tagwidget)) + length += tag_width + 1 + if cols: + part = urwid.Columns(cols, dividechars=1) + width = length + return width, part + + def rebuild(self): + self.widgets = [] + columns = [] + self.structure = settings.get_threadline_theming(self.thread) + for partname in self.structure['parts']: + minw = maxw = None + width_tuple = self.structure[partname]['width'] + if width_tuple is not None: + if width_tuple[0] == 'fit': + minw, maxw = width_tuple[1:] + align_mode = self.structure[partname]['alignment'] + width, part = self._build_part(partname, self.structure, + minw, maxw, align_mode) + if part is not None: + if isinstance(part, urwid.Columns): + for w in part.widget_list: + self.widgets.append(w) + else: + self.widgets.append(part) + + # compute width and align + if width_tuple[0] == 'weight': + columnentry = width_tuple + (part,) + else: + columnentry = ('fixed', width, part) + columns.append(columnentry) + self.columns = urwid.Columns(columns, dividechars=1) + self.original_widget = self.columns + + def render(self, size, focus=False): + for w in self.widgets: + w.set_map('focus' if focus else 'normal') + return urwid.AttrMap.render(self, size, focus) + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + def get_thread(self): + return self.thread + + def _get_theme(self, component, focus=False): + path = ['search', 'threadline', component] + if focus: + path.append('focus') + else: + path.append('normal') + return settings.get_theming_attribute(path) diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py new file mode 100644 index 00000000..c6d399e8 --- /dev/null +++ b/alot/widgets/thread.py @@ -0,0 +1,301 @@ +# Copyright (C) 2011-2012 Patrick Totzke +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +""" +Widgets specific to thread mode +""" +import urwid +import logging + +from alot.settings import settings +from alot.db.utils import decode_header +import alot.db.message as message +from alot.helper import tag_cmp +from alot.widgets.globals import HeadersList +from alot.widgets.globals import TagWidget +from alot.widgets.globals import AttachmentWidget + + +class MessageWidget(urwid.WidgetWrap): + """ + Flow widget that renders a :class:`~alot.db.message.Message`. + """ + #TODO: atm this is heavily bent to work nicely with ThreadBuffer to display + #a tree structure. A better way would be to keep this widget simple + #(subclass urwid.Pile) and use urwids new Tree widgets + def __init__(self, message, even=False, folded=True, raw=False, + all_headers=False, depth=0, bars_at=[]): + """ + :param message: the message to display + :type message: alot.db.Message + :param even: use messagesummary_even theme for summary + :type even: bool + :param folded: fold message initially + :type folded: bool + :param raw: show message source initially + :type raw: bool + :param all_headers: show all headers initially + :type all_headers: bool + :param depth: number of characters to shift content to the right + :type depth: int + :param bars_at: defines for each column of the indentation whether to + use a vertical bar instead of a space. + :type bars_at: list(bool) + """ + self.message = message + self.mail = self.message.get_email() + + self.depth = depth + self.bars_at = bars_at + self.even = even + self.folded = folded + self.show_raw = raw + self.show_all_headers = all_headers + + # define subwidgets that will be created on demand + self.sumline = None + self.headerw = None + self.attachmentw = None + self.bodyw = None + self.sourcew = None + + # set available and to be displayed headers + self._all_headers = list(set(self.mail.keys())) + displayed = settings.get('displayed_headers') + self._filtered_headers = [k for k in displayed if k in self.mail] + self._displayed_headers = None + + bars = settings.get_theming_attribute('thread', 'arrow_bars') + self.arrow_bars_att = bars + heads = settings.get_theming_attribute('thread', 'arrow_heads') + self.arrow_heads_att = heads + logging.debug(self.arrow_heads_att) + + self.rebuild() # this will build self.pile + urwid.WidgetWrap.__init__(self, self.pile) + + def get_focus(self): + return self.pile.get_focus() + + def rebuild(self): + self.sumline = self._build_sum_line() + if not self.folded: # only if already unfolded + self.displayed_list = [self.sumline] + if self.show_raw: + srcw = self._get_source_widget() + self.displayed_list.append(srcw) + else: + hw = self._get_header_widget() + aw = self._get_attachment_widget() + bw = self._get_body_widget() + if hw: + self.displayed_list.append(hw) + if aw: + self.displayed_list.append(aw) + self.displayed_list.append(bw) + else: + self.displayed_list = [self.sumline] + self.pile = urwid.Pile(self.displayed_list) + self._w = self.pile + + def _build_sum_line(self): + """creates/returns the widget that displays the summary line.""" + self.sumw = MessageSummaryWidget(self.message, even=self.even) + cols = [] + bc = list() # box_columns + if self.depth > 1: + bc.append(0) + spacer = self._get_spacer(self.bars_at[1:-1]) + cols.append(spacer) + if self.depth > 0: + if self.bars_at[-1]: + arrowhead = [(self.arrow_bars_att, u'\u251c'), + (self.arrow_heads_att, u'\u25b6')] + else: + arrowhead = [(self.arrow_bars_att, u'\u2514'), + (self.arrow_heads_att, u'\u25b6')] + cols.append(('fixed', 2, urwid.Text(arrowhead))) + cols.append(self.sumw) + line = urwid.Columns(cols, box_columns=bc) + return line + + def _get_header_widget(self): + """creates/returns the widget that displays the mail header""" + all_shown = (self._all_headers == self._displayed_headers) + + if self.headerw and (self.show_all_headers == all_shown): + return self.headerw + + if self.show_all_headers: + self._displayed_headers = self._all_headers + else: + self._displayed_headers = self._filtered_headers + + mail = self.message.get_email() + # normalize values if only filtered list is shown + norm = not (self._displayed_headers == self._all_headers) + + #build lines + lines = [] + for key in self._displayed_headers: + if key in mail: + if key.lower() in ['cc', 'bcc', 'to']: + values = mail.get_all(key) + values = [decode_header(v, normalize=norm) for v in values] + lines.append((key, ', '.join(values))) + else: + for value in mail.get_all(key): + dvalue = decode_header(value, normalize=norm) + lines.append((key, dvalue)) + + key_att = settings.get_theming_attribute('thread', 'header_key') + value_att = settings.get_theming_attribute('thread', 'header_value') + cols = [HeadersList(lines, key_att, value_att)] + bc = list() + if self.depth: + cols.insert(0, self._get_spacer(self.bars_at[1:])) + bc.append(0) + cols.insert(1, self._get_arrowhead_aligner()) + bc.append(1) + self.headerw = urwid.Columns(cols, box_columns=bc) + return self.headerw + + def _get_attachment_widget(self): + if self.message.get_attachments() and not self.attachmentw: + lines = [] + for a in self.message.get_attachments(): + cols = [AttachmentWidget(a)] + bc = list() + if self.depth: + cols.insert(0, self._get_spacer(self.bars_at[1:])) + bc.append(0) + cols.insert(1, self._get_arrowhead_aligner()) + bc.append(1) + lines.append(urwid.Columns(cols, box_columns=bc)) + self.attachmentw = urwid.Pile(lines) + return self.attachmentw + + def _get_body_widget(self): + """creates/returns the widget that displays the mail body""" + if not self.bodyw: + cols = [MessageBodyWidget(self.message.get_email())] + bc = list() + if self.depth: + cols.insert(0, self._get_spacer(self.bars_at[1:])) + bc.append(0) + cols.insert(1, self._get_arrowhead_aligner()) + bc.append(1) + self.bodyw = urwid.Columns(cols, box_columns=bc) + return self.bodyw + + def _get_source_widget(self): + """creates/returns the widget that displays the mail body""" + if not self.sourcew: + cols = [urwid.Text(self.message.get_email().as_string())] + bc = list() + if self.depth: + cols.insert(0, self._get_spacer(self.bars_at[1:])) + bc.append(0) + cols.insert(1, self._get_arrowhead_aligner()) + bc.append(1) + self.sourcew = urwid.Columns(cols, box_columns=bc) + return self.sourcew + + def _get_spacer(self, bars_at): + prefixchars = [] + length = len(bars_at) + for b in bars_at: + if b: + c = u'\u2502' + else: + c = ' ' + prefixchars.append(('fixed', 1, urwid.SolidFill(c))) + + spacer = urwid.Columns(prefixchars, box_columns=range(length)) + spacer = urwid.AttrMap(spacer, self.arrow_bars_att) + return ('fixed', length, spacer) + + def _get_arrowhead_aligner(self): + if self.message.has_replies(): + aligner = u'\u2502' + else: + aligner = ' ' + aligner = urwid.SolidFill(aligner) + return ('fixed', 1, urwid.AttrMap(aligner, self.arrow_bars_att)) + + def selectable(self): + return True + + def keypress(self, size, key): + return self.pile.keypress(size, key) + + def get_message(self): + """get contained :class`~alot.db.message.Message`""" + return self.message + + def get_email(self): + """get contained :class:`email `""" + return self.message.get_email() + + +class MessageSummaryWidget(urwid.WidgetWrap): + """ + one line summary of a :class:`~alot.db.message.Message`. + """ + + def __init__(self, message, even=True): + """ + :param message: a message + :type message: alot.db.Message + :param even: even entry in a pile of messages? Used for theming. + :type even: bool + """ + self.message = message + self.even = even + if even: + attr = settings.get_theming_attribute('thread', 'summary', 'even') + else: + attr = settings.get_theming_attribute('thread', 'summary', 'odd') + focus_att = settings.get_theming_attribute('thread', 'summary', + 'focus') + cols = [] + + sumstr = self.__str__() + txt = urwid.Text(sumstr) + cols.append(txt) + + thread_tags = message.get_thread().get_tags(intersection=True) + outstanding_tags = set(message.get_tags()).difference(thread_tags) + tag_widgets = [TagWidget(t, attr, focus_att) for t in outstanding_tags] + tag_widgets.sort(tag_cmp, lambda tag_widget: tag_widget.translated) + for tag_widget in tag_widgets: + if not tag_widget.hidden: + cols.append(('fixed', tag_widget.width(), tag_widget)) + line = urwid.AttrMap(urwid.Columns(cols, dividechars=1), attr, + focus_att) + urwid.WidgetWrap.__init__(self, line) + + def __str__(self): + author, address = self.message.get_author() + date = self.message.get_datestring() + rep = author if author != '' else address + if date is not None: + rep += " (%s)" % date + return rep + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + +class MessageBodyWidget(urwid.AttrMap): + """ + displays printable parts of an email + """ + + def __init__(self, msg): + bodytxt = message.extract_body(msg) + att = settings.get_theming_attribute('thread', 'body') + urwid.AttrMap.__init__(self, urwid.Text(bodytxt), att) diff --git a/alot/widgets/utils.py b/alot/widgets/utils.py new file mode 100644 index 00000000..b50b2db9 --- /dev/null +++ b/alot/widgets/utils.py @@ -0,0 +1,64 @@ +# Copyright (C) 2011-2012 Patrick Totzke +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +""" +Utility Widgets not specific to alot +""" +import urwid +import logging + + +class AttrFlipWidget(urwid.AttrMap): + """ + An AttrMap that can remember attributes to set + """ + def __init__(self, w, maps, init_map='normal'): + self.maps = maps + urwid.AttrMap.__init__(self, w, maps[init_map]) + + def set_map(self, attrstring): + self.set_attr_map({None: self.maps[attrstring]}) + + +class DialogBox(urwid.WidgetWrap): + def __init__(self, body, title, bodyattr=None, titleattr=None): + self.body = urwid.LineBox(body) + self.title = urwid.Text(title) + if titleattr is not None: + self.title = urwid.AttrMap(self.title, titleattr) + if bodyattr is not None: + self.body = urwid.AttrMap(self.body, bodyattr) + + box = urwid.Overlay(self.title, self.body, + align='center', + valign='top', + width=len(title), + height=None, + ) + urwid.WidgetWrap.__init__(self, box) + + def selectable(self): + return self.body.selectable() + + def keypress(self, size, key): + return self.body.keypress(size, key) + + +class CatchKeyWidgetWrap(urwid.WidgetWrap): + def __init__(self, widget, key, on_catch, relay_rest=True): + urwid.WidgetWrap.__init__(self, widget) + self.key = key + self.relay = relay_rest + self.on_catch = on_catch + + def selectable(self): + return True + + def keypress(self, size, key): + logging.debug('CATCH KEY: %s' % key) + logging.debug('relay: %s' % self.relay) + if key == self.key: + self.on_catch() + elif self._w.selectable() and self.relay: + return self._w.keypress(size, key) diff --git a/setup.py b/setup.py index c754eeed..f5d5858e 100755 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup(name='alot', url=alot.__url__, license=alot.__copyright__, packages=['alot', 'alot.commands', 'alot.settings', 'alot.db', - 'alot.utils'], + 'alot.utils', 'alot.widgets'], package_data={'alot': [ 'defaults/alot.rc.spec', 'defaults/notmuch.rc.spec', -- cgit v1.2.3 From a23e6fd04b08a2c9af08f25cd0bdf6c6282a8439 Mon Sep 17 00:00:00 2001 From: Patrick Totzke Date: Sun, 5 Aug 2012 12:51:47 +0100 Subject: cleanup: move SettingsManager to its own file --- alot/init.py | 10 +- alot/settings/__init__.py | 394 +--------------------------------------------- alot/settings/manager.py | 394 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+), 398 deletions(-) create mode 100644 alot/settings/manager.py diff --git a/alot/init.py b/alot/init.py index aaa2f22c..732a674d 100755 --- a/alot/init.py +++ b/alot/init.py @@ -5,13 +5,13 @@ import sys import logging import os -from settings import settings, ConfigError -from db import DBManager -from ui import UI +from alot.settings import settings +from alot.settings.errors import ConfigError +from alot.db import DBManager +from alot.ui import UI import alot.commands as commands -from commands import * +from alot.commands import * from alot.commands import CommandParseError -import alot from twisted.python import usage diff --git a/alot/settings/__init__.py b/alot/settings/__init__.py index a92eaf0e..57c351b7 100644 --- a/alot/settings/__init__.py +++ b/alot/settings/__init__.py @@ -1,398 +1,6 @@ # Copyright (C) 2011-2012 Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file -import imp -import os -import re -import errno -import mailcap -import logging -import shutil -from configobj import ConfigObj, Section - -from alot.account import SendmailAccount -from alot.addressbooks import MatchSdtoutAddressbook, AbookAddressBook -from alot.helper import pretty_datetime, string_decode - -from errors import ConfigError -from utils import read_config -from utils import resolve_att -from checks import force_list -from checks import mail_container -from checks import gpg_key -from checks import attr_triple -from checks import align_mode -from theme import Theme - - -DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults') - - -class SettingsManager(object): - """Organizes user settings""" - def __init__(self, alot_rc=None, notmuch_rc=None, theme=None): - """ - :param alot_rc: path to alot's config file - :type alot_rc: str - :param notmuch_rc: path to notmuch's config file - :type notmuch_rc: str - :theme: path to initially used theme file - :type theme: str - """ - self.hooks = None - self._mailcaps = mailcap.getcaps() - - theme_path = theme or os.path.join(DEFAULTSPATH, 'default.theme') - self._theme = Theme(theme_path) - bindings_path = os.path.join(DEFAULTSPATH, 'default.bindings') - self._bindings = ConfigObj(bindings_path) - self._config = ConfigObj() - self._accounts = None - self._accountmap = None - self.read_config(alot_rc) - self.read_notmuch_config(notmuch_rc) - - def read_notmuch_config(self, path): - """parse notmuch's config file from path""" - spec = os.path.join(DEFAULTSPATH, 'notmuch.rc.spec') - self._notmuchconfig = read_config(path, spec) - - def read_config(self, path): - """parse alot's config file from path""" - spec = os.path.join(DEFAULTSPATH, 'alot.rc.spec') - newconfig = read_config(path, spec, - checks={'mail_container': mail_container, - 'force_list': force_list, - 'align': align_mode, - 'attrtriple': attr_triple, - 'gpg_key_hint': gpg_key}) - self._config.merge(newconfig) - - hooks_path = os.path.expanduser(self._config.get('hooksfile')) - try: - self.hooks = imp.load_source('hooks', hooks_path) - except: - logging.debug('unable to load hooks file:%s' % hooks_path) - if 'bindings' in newconfig: - newbindings = newconfig['bindings'] - if isinstance(newbindings, Section): - self._bindings.merge(newbindings) - # themes - themestring = newconfig['theme'] - themes_dir = self._config.get('themes_dir') - if themes_dir: - themes_dir = os.path.expanduser(themes_dir) - else: - themes_dir = os.path.join(os.environ.get('XDG_CONFIG_HOME', - os.path.expanduser('~/.config')), 'alot', 'themes') - logging.debug(themes_dir) - - if themestring: - if not os.path.isdir(themes_dir): - err_msg = 'cannot find theme %s: themes_dir %s is missing' - raise ConfigError(err_msg % (themestring, themes_dir)) - else: - theme_path = os.path.join(themes_dir, themestring) - try: - self._theme = Theme(theme_path) - except ConfigError as e: - err_msg = 'Theme file %s failed validation:\n' - raise ConfigError((err_msg % themestring) + e.message) - - self._accounts = self._parse_accounts(self._config) - self._accountmap = self._account_table(self._accounts) - - def _parse_accounts(self, config): - """ - read accounts information from config - - :param config: valit alot config - :type config: `configobj.ConfigObj` - :returns: list of accounts - """ - accounts = [] - if 'accounts' in config: - for acc in config['accounts'].sections: - accsec = config['accounts'][acc] - args = dict(config['accounts'][acc]) - - # create abook for this account - abook = accsec['abook'] - logging.debug('abook defined: %s' % abook) - if abook['type'] == 'shellcommand': - cmd = abook['command'] - regexp = abook['regexp'] - if cmd is not None and regexp is not None: - args['abook'] = MatchSdtoutAddressbook(cmd, - match=regexp) - else: - msg = 'underspecified abook of type \'shellcommand\':' - msg += '\ncommand: %s\nregexp:%s' % (cmd, regexp) - raise ConfigError(msg) - elif abook['type'] == 'abook': - contacts_path = abook['abook_contacts_file'] - args['abook'] = AbookAddressBook(contacts_path) - else: - del(args['abook']) - - cmd = args['sendmail_command'] - del(args['sendmail_command']) - newacc = SendmailAccount(cmd, **args) - accounts.append(newacc) - return accounts - - def _account_table(self, accounts): - """ - creates a lookup table (emailaddress -> account) for a given list of - accounts - - :param accounts: list of accounts - :type accounts: list of `alot.account.Account` - :returns: hashtable - :rvalue: dict (str -> `alot.account.Account`) - """ - accountmap = {} - for acc in accounts: - accountmap[acc.address] = acc - for alias in acc.aliases: - accountmap[alias] = acc - return accountmap - - def get(self, key, fallback=None): - """ - look up global config values from alot's config - - :param key: key to look up - :type key: str - :param fallback: fallback returned if key is not present - :type fallback: str - :returns: config value with type as specified in the spec-file - """ - value = None - if key in self._config: - value = self._config[key] - if isinstance(value, Section): - value = None - if value == None: - value = fallback - return value - - def set(self, key, value): - """ - setter for global config values - - :param key: config option identifise - :type key: str - :param value: option to set - :type value: depends on the specfile :file:`alot.rc.spec` - """ - self._config[key] = value - - def get_notmuch_setting(self, section, key, fallback=None): - """ - look up config values from notmuch's config - - :param section: key is in - :type section: str - :param key: key to look up - :type key: str - :param fallback: fallback returned if key is not present - :type fallback: str - :returns: config value with type as specified in the spec-file - """ - value = None - if section in self._notmuchconfig: - if key in self._notmuchconfig[section]: - value = self._notmuchconfig[section][key] - if value == None: - value = fallback - return value - - def get_theming_attribute(self, mode, name, part=None): - """ - looks up theming attribute - - :param mode: ui-mode (e.g. `search`,`thread`...) - :type mode: str - :param name: identifier of the atttribute - :type name: str - :rtype: urwid.AttrSpec - """ - colours = int(self._config.get('colourmode')) - return self._theme.get_attribute(colours, mode, name, part) - - def get_threadline_theming(self, thread): - """ - looks up theming info a threadline displaying a given thread. This - wraps around :meth:`~alot.settings.theme.Theme.get_threadline_theming`, - filling in the current colour mode. - - :param thread: thread to theme - :type thread: alot.db.thread.Thread - """ - colours = int(self._config.get('colourmode')) - return self._theme.get_threadline_theming(thread, colours) - - def get_tagstring_representation(self, tag, onebelow_normal=None, - onebelow_focus=None): - """ - looks up user's preferred way to represent a given tagstring. - - :param tag: tagstring - :type tag: str - :param onebelow_normal: attribute that shines through if unfocussed - :type onebelow_normal: urwid.AttrSpec - :param onebelow_focus: attribute that shines through if focussed - :type onebelow_focus: urwid.AttrSpec - - If `onebelow_normal` or `onebelow_focus` is given these attributes will - be used as fallbacks for fg/bg values '' and 'default'. - - This returns a dictionary mapping - :normal: to :class:`urwid.AttrSpec` used if unfocussed - :focussed: to :class:`urwid.AttrSpec` used if focussed - :translated: to an alternative string representation - """ - colourmode = int(self._config.get('colourmode')) - theme = self._theme - cfg = self._config - colours = [1, 16, 256] - - def colourpick(triple): - """ pick attribute from triple (mono,16c,256c) according to current - colourmode""" - if triple is None: - return None - return triple[colours.index(colourmode)] - - # global default attributes for tagstrings. - # These could contain values '' and 'default' which we interpret as - # "use the values from the widget below" - default_normal = theme.get_attribute(colourmode, 'global', 'tag') - default_focus = theme.get_attribute(colourmode, 'global', 'tag_focus') - - # local defaults for tagstring attributes. depend on next lower widget - fallback_normal = resolve_att(onebelow_normal, default_normal) - fallback_focus = resolve_att(onebelow_focus, default_focus) - - for sec in cfg['tags'].sections: - if re.match('^' + sec + '$', tag): - normal = resolve_att(colourpick(cfg['tags'][sec]['normal']), - fallback_normal) - focus = resolve_att(colourpick(cfg['tags'][sec]['focus']), - fallback_focus) - - translated = cfg['tags'][sec]['translated'] - if translated is None: - translated = tag - translation = cfg['tags'][sec]['translation'] - if translation: - translated = re.sub(translation[0], translation[1], tag) - break - else: - normal = fallback_normal - focus = fallback_focus - translated = tag - - return {'normal': normal, 'focussed': focus, 'translated': translated} - - def get_hook(self, key): - """return hook (`callable`) identified by `key`""" - if self.hooks: - if key in self.hooks.__dict__: - return self.hooks.__dict__[key] - return None - - def get_keybinding(self, mode, key): - """look up keybinding from `MODE-maps` sections - - :param mode: mode identifier - :type mode: str - :param key: urwid-style key identifier - :type key: str - :returns: a command line to be applied upon keypress - :rtype: str - """ - cmdline = None - bindings = self._bindings - if key in bindings.scalars: - cmdline = bindings[key] - if mode in bindings.sections: - if key in bindings[mode].scalars: - value = bindings[mode][key] - if value: - cmdline = value - return cmdline - - def get_accounts(self): - """ - returns known accounts - - :rtype: list of :class:`Account` - """ - return self._accounts - - def get_account_by_address(self, address): - """ - returns :class:`Account` for a given email address (str) - - :param address: address to look up - :type address: string - :rtype: :class:`Account` or None - """ - - for myad in self.get_addresses(): - if myad in address: - return self._accountmap[myad] - return None - - def get_main_addresses(self): - """returns addresses of known accounts without its aliases""" - return [a.address for a in self._accounts] - - def get_addresses(self): - """returns addresses of known accounts including all their aliases""" - return self._accountmap.keys() - - def get_addressbooks(self, order=[], append_remaining=True): - """returns list of all defined :class:`AddressBook` objects""" - abooks = [] - for a in order: - if a: - if a.abook: - abooks.append(a.abook) - if append_remaining: - for a in self._accounts: - if a.abook and a.abook not in abooks: - abooks.append(a.abook) - return abooks - - def mailcap_find_match(self, *args, **kwargs): - """ - Propagates :func:`mailcap.find_match` but caches the mailcap (first - argument) - """ - return mailcap.findmatch(self._mailcaps, *args, **kwargs) - - def represent_datetime(self, d): - """ - turns a given datetime obj into a unicode string representation. - This will: - - 1) look if a fixed 'timestamp_format' is given in the config - 2) check if a 'timestamp_format' hook is defined - 3) use :func:`~alot.helper.pretty_datetime` as fallback - """ - - fixed_format = self.get('timestamp_format') - if fixed_format: - rep = string_decode(d.strftime(fixed_format), 'UTF-8') - else: - format_hook = self.get_hook('timestamp_format') - if format_hook: - rep = string_decode(format_hook(d), 'UTF-8') - else: - rep = pretty_datetime(d) - return rep +from alot.settings.manager import SettingsManager settings = SettingsManager() diff --git a/alot/settings/manager.py b/alot/settings/manager.py new file mode 100644 index 00000000..c336071c --- /dev/null +++ b/alot/settings/manager.py @@ -0,0 +1,394 @@ +# Copyright (C) 2011-2012 Patrick Totzke +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +import imp +import os +import re +import mailcap +import logging +from configobj import ConfigObj, Section + +from alot.account import SendmailAccount +from alot.addressbooks import MatchSdtoutAddressbook, AbookAddressBook +from alot.helper import pretty_datetime, string_decode + +from errors import ConfigError +from utils import read_config +from utils import resolve_att +from checks import force_list +from checks import mail_container +from checks import gpg_key +from checks import attr_triple +from checks import align_mode +from theme import Theme + + +DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults') + + +class SettingsManager(object): + """Organizes user settings""" + def __init__(self, alot_rc=None, notmuch_rc=None, theme=None): + """ + :param alot_rc: path to alot's config file + :type alot_rc: str + :param notmuch_rc: path to notmuch's config file + :type notmuch_rc: str + :theme: path to initially used theme file + :type theme: str + """ + self.hooks = None + self._mailcaps = mailcap.getcaps() + + theme_path = theme or os.path.join(DEFAULTSPATH, 'default.theme') + self._theme = Theme(theme_path) + bindings_path = os.path.join(DEFAULTSPATH, 'default.bindings') + self._bindings = ConfigObj(bindings_path) + self._config = ConfigObj() + self._accounts = None + self._accountmap = None + self.read_config(alot_rc) + self.read_notmuch_config(notmuch_rc) + + def read_notmuch_config(self, path): + """parse notmuch's config file from path""" + spec = os.path.join(DEFAULTSPATH, 'notmuch.rc.spec') + self._notmuchconfig = read_config(path, spec) + + def read_config(self, path): + """parse alot's config file from path""" + spec = os.path.join(DEFAULTSPATH, 'alot.rc.spec') + newconfig = read_config(path, spec, + checks={'mail_container': mail_container, + 'force_list': force_list, + 'align': align_mode, + 'attrtriple': attr_triple, + 'gpg_key_hint': gpg_key}) + self._config.merge(newconfig) + + hooks_path = os.path.expanduser(self._config.get('hooksfile')) + try: + self.hooks = imp.load_source('hooks', hooks_path) + except: + logging.debug('unable to load hooks file:%s' % hooks_path) + if 'bindings' in newconfig: + newbindings = newconfig['bindings'] + if isinstance(newbindings, Section): + self._bindings.merge(newbindings) + # themes + themestring = newconfig['theme'] + themes_dir = self._config.get('themes_dir') + if themes_dir: + themes_dir = os.path.expanduser(themes_dir) + else: + themes_dir = os.path.join(os.environ.get('XDG_CONFIG_HOME', + os.path.expanduser('~/.config')), 'alot', 'themes') + logging.debug(themes_dir) + + if themestring: + if not os.path.isdir(themes_dir): + err_msg = 'cannot find theme %s: themes_dir %s is missing' + raise ConfigError(err_msg % (themestring, themes_dir)) + else: + theme_path = os.path.join(themes_dir, themestring) + try: + self._theme = Theme(theme_path) + except ConfigError as e: + err_msg = 'Theme file %s failed validation:\n' + raise ConfigError((err_msg % themestring) + e.message) + + self._accounts = self._parse_accounts(self._config) + self._accountmap = self._account_table(self._accounts) + + def _parse_accounts(self, config): + """ + read accounts information from config + + :param config: valit alot config + :type config: `configobj.ConfigObj` + :returns: list of accounts + """ + accounts = [] + if 'accounts' in config: + for acc in config['accounts'].sections: + accsec = config['accounts'][acc] + args = dict(config['accounts'][acc]) + + # create abook for this account + abook = accsec['abook'] + logging.debug('abook defined: %s' % abook) + if abook['type'] == 'shellcommand': + cmd = abook['command'] + regexp = abook['regexp'] + if cmd is not None and regexp is not None: + args['abook'] = MatchSdtoutAddressbook(cmd, + match=regexp) + else: + msg = 'underspecified abook of type \'shellcommand\':' + msg += '\ncommand: %s\nregexp:%s' % (cmd, regexp) + raise ConfigError(msg) + elif abook['type'] == 'abook': + contacts_path = abook['abook_contacts_file'] + args['abook'] = AbookAddressBook(contacts_path) + else: + del(args['abook']) + + cmd = args['sendmail_command'] + del(args['sendmail_command']) + newacc = SendmailAccount(cmd, **args) + accounts.append(newacc) + return accounts + + def _account_table(self, accounts): + """ + creates a lookup table (emailaddress -> account) for a given list of + accounts + + :param accounts: list of accounts + :type accounts: list of `alot.account.Account` + :returns: hashtable + :rvalue: dict (str -> `alot.account.Account`) + """ + accountmap = {} + for acc in accounts: + accountmap[acc.address] = acc + for alias in acc.aliases: + accountmap[alias] = acc + return accountmap + + def get(self, key, fallback=None): + """ + look up global config values from alot's config + + :param key: key to look up + :type key: str + :param fallback: fallback returned if key is not present + :type fallback: str + :returns: config value with type as specified in the spec-file + """ + value = None + if key in self._config: + value = self._config[key] + if isinstance(value, Section): + value = None + if value is None: + value = fallback + return value + + def set(self, key, value): + """ + setter for global config values + + :param key: config option identifise + :type key: str + :param value: option to set + :type value: depends on the specfile :file:`alot.rc.spec` + """ + self._config[key] = value + + def get_notmuch_setting(self, section, key, fallback=None): + """ + look up config values from notmuch's config + + :param section: key is in + :type section: str + :param key: key to look up + :type key: str + :param fallback: fallback returned if key is not present + :type fallback: str + :returns: config value with type as specified in the spec-file + """ + value = None + if section in self._notmuchconfig: + if key in self._notmuchconfig[section]: + value = self._notmuchconfig[section][key] + if value is None: + value = fallback + return value + + def get_theming_attribute(self, mode, name, part=None): + """ + looks up theming attribute + + :param mode: ui-mode (e.g. `search`,`thread`...) + :type mode: str + :param name: identifier of the atttribute + :type name: str + :rtype: urwid.AttrSpec + """ + colours = int(self._config.get('colourmode')) + return self._theme.get_attribute(colours, mode, name, part) + + def get_threadline_theming(self, thread): + """ + looks up theming info a threadline displaying a given thread. This + wraps around :meth:`~alot.settings.theme.Theme.get_threadline_theming`, + filling in the current colour mode. + + :param thread: thread to theme + :type thread: alot.db.thread.Thread + """ + colours = int(self._config.get('colourmode')) + return self._theme.get_threadline_theming(thread, colours) + + def get_tagstring_representation(self, tag, onebelow_normal=None, + onebelow_focus=None): + """ + looks up user's preferred way to represent a given tagstring. + + :param tag: tagstring + :type tag: str + :param onebelow_normal: attribute that shines through if unfocussed + :type onebelow_normal: urwid.AttrSpec + :param onebelow_focus: attribute that shines through if focussed + :type onebelow_focus: urwid.AttrSpec + + If `onebelow_normal` or `onebelow_focus` is given these attributes will + be used as fallbacks for fg/bg values '' and 'default'. + + This returns a dictionary mapping + :normal: to :class:`urwid.AttrSpec` used if unfocussed + :focussed: to :class:`urwid.AttrSpec` used if focussed + :translated: to an alternative string representation + """ + colourmode = int(self._config.get('colourmode')) + theme = self._theme + cfg = self._config + colours = [1, 16, 256] + + def colourpick(triple): + """ pick attribute from triple (mono,16c,256c) according to current + colourmode""" + if triple is None: + return None + return triple[colours.index(colourmode)] + + # global default attributes for tagstrings. + # These could contain values '' and 'default' which we interpret as + # "use the values from the widget below" + default_normal = theme.get_attribute(colourmode, 'global', 'tag') + default_focus = theme.get_attribute(colourmode, 'global', 'tag_focus') + + # local defaults for tagstring attributes. depend on next lower widget + fallback_normal = resolve_att(onebelow_normal, default_normal) + fallback_focus = resolve_att(onebelow_focus, default_focus) + + for sec in cfg['tags'].sections: + if re.match('^' + sec + '$', tag): + normal = resolve_att(colourpick(cfg['tags'][sec]['normal']), + fallback_normal) + focus = resolve_att(colourpick(cfg['tags'][sec]['focus']), + fallback_focus) + + translated = cfg['tags'][sec]['translated'] + if translated is None: + translated = tag + translation = cfg['tags'][sec]['translation'] + if translation: + translated = re.sub(translation[0], translation[1], tag) + break + else: + normal = fallback_normal + focus = fallback_focus + translated = tag + + return {'normal': normal, 'focussed': focus, 'translated': translated} + + def get_hook(self, key): + """return hook (`callable`) identified by `key`""" + if self.hooks: + if key in self.hooks.__dict__: + return self.hooks.__dict__[key] + return None + + def get_keybinding(self, mode, key): + """look up keybinding from `MODE-maps` sections + + :param mode: mode identifier + :type mode: str + :param key: urwid-style key identifier + :type key: str + :returns: a command line to be applied upon keypress + :rtype: str + """ + cmdline = None + bindings = self._bindings + if key in bindings.scalars: + cmdline = bindings[key] + if mode in bindings.sections: + if key in bindings[mode].scalars: + value = bindings[mode][key] + if value: + cmdline = value + return cmdline + + def get_accounts(self): + """ + returns known accounts + + :rtype: list of :class:`Account` + """ + return self._accounts + + def get_account_by_address(self, address): + """ + returns :class:`Account` for a given email address (str) + + :param address: address to look up + :type address: string + :rtype: :class:`Account` or None + """ + + for myad in self.get_addresses(): + if myad in address: + return self._accountmap[myad] + return None + + def get_main_addresses(self): + """returns addresses of known accounts without its aliases""" + return [a.address for a in self._accounts] + + def get_addresses(self): + """returns addresses of known accounts including all their aliases""" + return self._accountmap.keys() + + def get_addressbooks(self, order=[], append_remaining=True): + """returns list of all defined :class:`AddressBook` objects""" + abooks = [] + for a in order: + if a: + if a.abook: + abooks.append(a.abook) + if append_remaining: + for a in self._accounts: + if a.abook and a.abook not in abooks: + abooks.append(a.abook) + return abooks + + def mailcap_find_match(self, *args, **kwargs): + """ + Propagates :func:`mailcap.find_match` but caches the mailcap (first + argument) + """ + return mailcap.findmatch(self._mailcaps, *args, **kwargs) + + def represent_datetime(self, d): + """ + turns a given datetime obj into a unicode string representation. + This will: + + 1) look if a fixed 'timestamp_format' is given in the config + 2) check if a 'timestamp_format' hook is defined + 3) use :func:`~alot.helper.pretty_datetime` as fallback + """ + + fixed_format = self.get('timestamp_format') + if fixed_format: + rep = string_decode(d.strftime(fixed_format), 'UTF-8') + else: + format_hook = self.get_hook('timestamp_format') + if format_hook: + rep = string_decode(format_hook(d), 'UTF-8') + else: + rep = pretty_datetime(d) + return rep -- cgit v1.2.3 From 50293143ea58eca364d32bae14c422d4ba782d9f Mon Sep 17 00:00:00 2001 From: Patrick Totzke Date: Sun, 5 Aug 2012 12:58:07 +0100 Subject: cleanup: move DBManager to its own file --- alot/db/__init__.py | 361 +-------------------------------------------------- alot/db/manager.py | 363 ++++++++++++++++++++++++++++++++++++++++++++++++++++ alot/init.py | 2 +- 3 files changed, 365 insertions(+), 361 deletions(-) create mode 100644 alot/db/manager.py diff --git a/alot/db/__init__.py b/alot/db/__init__.py index beb630d6..ae067ce3 100644 --- a/alot/db/__init__.py +++ b/alot/db/__init__.py @@ -1,364 +1,5 @@ # Copyright (C) 2011-2012 Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file -from notmuch import Database, NotmuchError, XapianError -import notmuch -import multiprocessing -import logging -from collections import deque - -from message import Message -from alot.settings import settings -from thread import Thread -from errors import DatabaseError -from errors import DatabaseLockedError -from errors import DatabaseROError -from errors import NonexistantObjectError - -DB_ENC = 'utf-8' - - -class FillPipeProcess(multiprocessing.Process): - def __init__(self, it, pipe, fun=(lambda x: x)): - multiprocessing.Process.__init__(self) - self.it = it - self.pipe = pipe[1] - self.fun = fun - - def run(self): - for a in self.it: - self.pipe.send(self.fun(a)) - self.pipe.close() - - -class DBManager(object): - """ - Keeps track of your index parameters, maintains a write-queue and - lets you look up threads and messages directly to the persistent wrapper - classes. - """ - _sort_orders = { - 'oldest_first': notmuch.database.Query.SORT.OLDEST_FIRST, - 'newest_first': notmuch.database.Query.SORT.NEWEST_FIRST, - 'unsorted': notmuch.database.Query.SORT.UNSORTED, - 'message_id': notmuch.database.Query.SORT.MESSAGE_ID, - } - """constants representing sort orders""" - - def __init__(self, path=None, ro=False): - """ - :param path: absolute path to the notmuch index - :type path: str - :param ro: open the index in read-only mode - :type ro: bool - """ - self.ro = ro - self.path = path - self.writequeue = deque([]) - self.processes = [] - - def flush(self): - """ - write out all queued write-commands in order, each one in a separate - :meth:`atomic ` transaction. - - If this fails the current action is rolled back, stays in the write - queue and an exception is raised. - You are responsible to retry flushing at a later time if you want to - ensure that the cached changes are applied to the database. - - :exception: :exc:`~errors.DatabaseROError` if db is opened read-only - :exception: :exc:`~errors.DatabaseLockedError` if db is locked - """ - if self.ro: - raise DatabaseROError() - if self.writequeue: - # read notmuch's config regarding imap flag synchronization - sync = settings.get_notmuch_setting('maildir', 'synchronize_flags') - - # go through writequeue entries - while self.writequeue: - current_item = self.writequeue.popleft() - logging.debug('write-out item: %s' % str(current_item)) - - # watch out for notmuch errors to re-insert current_item - # to the queue on errors - try: - # the first two coordinants are cnmdname and post-callback - cmd, afterwards = current_item[:2] - logging.debug('cmd created') - - # aquire a writeable db handler - try: - mode = Database.MODE.READ_WRITE - db = Database(path=self.path, mode=mode) - except NotmuchError: - raise DatabaseLockedError() - logging.debug('got write lock') - - # make this a transaction - db.begin_atomic() - logging.debug('got atomic') - - if cmd == 'add': - logging.debug('add') - path, tags = current_item[2:] - msg, status = db.add_message(path, - sync_maildir_flags=sync) - logging.debug('added msg') - msg.freeze() - logging.debug('freeze') - for tag in tags: - msg.add_tag(tag.encode(DB_ENC), - sync_maildir_flags=sync) - logging.debug('added tags ') - msg.thaw() - logging.debug('thaw') - - elif cmd == 'remove': - path = current_item[2] - db.remove_message(path) - - else: # tag/set/untag - querystring, tags = current_item[2:] - query = db.create_query(querystring) - for msg in query.search_messages(): - msg.freeze() - if cmd == 'tag': - for tag in tags: - msg.add_tag(tag.encode(DB_ENC), - sync_maildir_flags=sync) - if cmd == 'set': - msg.remove_all_tags() - for tag in tags: - msg.add_tag(tag.encode(DB_ENC), - sync_maildir_flags=sync) - elif cmd == 'untag': - for tag in tags: - msg.remove_tag(tag.encode(DB_ENC), - sync_maildir_flags=sync) - msg.thaw() - - logging.debug('ended atomic') - # end transaction and reinsert queue item on error - if db.end_atomic() != notmuch.STATUS.SUCCESS: - raise DatabaseError('end_atomic failed') - logging.debug('ended atomic') - - # close db - db.close() - logging.debug('closed db') - - # call post-callback - if callable(afterwards): - logging.debug(str(afterwards)) - afterwards() - logging.debug('called callback') - - # re-insert item to the queue upon Xapian/NotmuchErrors - except (XapianError, NotmuchError) as e: - logging.exception(e) - self.writequeue.appendleft(current_item) - raise DatabaseError(unicode(e)) - except DatabaseLockedError as e: - logging.debug('index temporarily locked') - self.writequeue.appendleft(current_item) - raise e - logging.debug('flush finished') - - def kill_search_processes(self): - """ - terminate all search processes that originate from - this managers :meth:`get_threads`. - """ - for p in self.processes: - p.terminate() - self.processes = [] - - def tag(self, querystring, tags, afterwards=None, remove_rest=False): - """ - add tags to messages matching `querystring`. - This appends a tag operation to the write queue and raises - :exc:`~errors.DatabaseROError` if in read only mode. - - :param querystring: notmuch search string - :type querystring: str - :param tags: a list of tags to be added - :type tags: list of str - :param afterwards: callback that gets called after successful - application of this tagging operation - :type afterwards: callable - :param remove_rest: remove tags from matching messages before tagging - :type remove_rest: bool - :exception: :exc:`~errors.DatabaseROError` - - .. note:: - This only adds the requested operation to the write queue. - You need to call :meth:`DBManager.flush` to actually write out. - """ - if self.ro: - raise DatabaseROError() - if remove_rest: - self.writequeue.append(('set', afterwards, querystring, tags)) - else: - self.writequeue.append(('tag', afterwards, querystring, tags)) - - def untag(self, querystring, tags, afterwards=None): - """ - removes tags from messages that match `querystring`. - This appends an untag operation to the write queue and raises - :exc:`~errors.DatabaseROError` if in read only mode. - - :param querystring: notmuch search string - :type querystring: str - :param tags: a list of tags to be added - :type tags: list of str - :param afterwards: callback that gets called after successful - application of this tagging operation - :type afterwards: callable - :exception: :exc:`~errors.DatabaseROError` - - .. note:: - This only adds the requested operation to the write queue. - You need to call :meth:`DBManager.flush` to actually write out. - """ - if self.ro: - raise DatabaseROError() - self.writequeue.append(('untag', afterwards, querystring, tags)) - - def count_messages(self, querystring): - """returns number of messages that match `querystring`""" - return self.query(querystring).count_messages() - - def count_threads(self, querystring): - """returns number of threads that match `querystring`""" - return self.query(querystring).count_threads() - - def search_thread_ids(self, querystring): - """ - returns the ids of all threads that match the `querystring` - This copies! all integer thread ids into an new list. - - :returns: list of str - """ - - return self.query_threaded(querystring) - - def _get_notmuch_thread(self, tid): - """returns :class:`notmuch.database.Thread` with given id""" - query = self.query('thread:' + tid) - try: - return query.search_threads().next() - except StopIteration: - errmsg = 'no thread with id %s exists!' % tid - raise NonexistantObjectError(errmsg) - - def get_thread(self, tid): - """returns :class:`Thread` with given thread id (str)""" - return Thread(self, self._get_notmuch_thread(tid)) - - def _get_notmuch_message(self, mid): - """returns :class:`notmuch.database.Message` with given id""" - mode = Database.MODE.READ_ONLY - db = Database(path=self.path, mode=mode) - try: - return db.find_message(mid) - except: - errmsg = 'no message with id %s exists!' % mid - raise NonexistantObjectError(errmsg) - - def get_message(self, mid): - """returns :class:`Message` with given message id (str)""" - return Message(self, self._get_notmuch_message(mid)) - - def get_all_tags(self): - """ - returns all tagsstrings used in the database - :rtype: list of str - """ - db = Database(path=self.path) - return [t for t in db.get_all_tags()] - - def async(self, cbl, fun): - """ - return a pair (pipe, process) so that the process writes - `fun(a)` to the pipe for each element `a` in the iterable returned - by the callable `cbl`. - - :param cbl: a function returning something iterable - :type cbl: callable - :param fun: an unary translation function - :type fun: callable - :rtype: (:class:`multiprocessing.Pipe`, - :class:`multiprocessing.Process`) - """ - pipe = multiprocessing.Pipe(False) - receiver, sender = pipe - process = FillPipeProcess(cbl(), pipe, fun) - process.start() - self.processes.append(process) - # closing the sending end in this (receiving) process guarantees - # that here the apropriate EOFError is raised upon .recv in the walker - sender.close() - return receiver, process - - def get_threads(self, querystring, sort='newest_first'): - """ - asynchronously look up thread ids matching `querystring`. - - :param querystring: The query string to use for the lookup - :type querystring: str. - :param sort: Sort order. one of ['oldest_first', 'newest_first', - 'message_id', 'unsorted'] - :type query: str - :returns: a pipe together with the process that asynchronously - writes to it. - :rtype: (:class:`multiprocessing.Pipe`, - :class:`multiprocessing.Process`) - """ - assert sort in self._sort_orders.keys() - q = self.query(querystring) - q.set_sort(self._sort_orders[sort]) - return self.async(q.search_threads, (lambda a: a.get_thread_id())) - - def query(self, querystring): - """ - creates :class:`notmuch.Query` objects on demand - - :param querystring: The query string to use for the lookup - :type query: str. - :returns: :class:`notmuch.Query` -- the query object. - """ - mode = Database.MODE.READ_ONLY - db = Database(path=self.path, mode=mode) - return db.create_query(querystring) - - def add_message(self, path, tags=[], afterwards=None): - """ - Adds a file to the notmuch index. - - :param path: path to the file - :type path: str - :param tags: tagstrings to add - :type tags: list of str - :param afterwards: callback to trigger after adding - :type afterwards: callable or None - """ - if self.ro: - raise DatabaseROError() - self.writequeue.append(('add', afterwards, path, tags)) - - def remove_message(self, message, afterwards=None): - """ - Remove a message from the notmuch index - - :param message: message to remove - :type message: :class:`Message` - :param afterwards: callback to trigger after removing - :type afterwards: callable or None - """ - if self.ro: - raise DatabaseROError() - path = message.get_filename() - self.writequeue.append(('remove', afterwards, path)) +DB_ENC = 'UTF-8' diff --git a/alot/db/manager.py b/alot/db/manager.py new file mode 100644 index 00000000..c52da7b2 --- /dev/null +++ b/alot/db/manager.py @@ -0,0 +1,363 @@ +# Copyright (C) 2011-2012 Patrick Totzke +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +from notmuch import Database, NotmuchError, XapianError +import notmuch +import multiprocessing +import logging + +from collections import deque + +from message import Message +from alot.settings import settings +from thread import Thread +from errors import DatabaseError +from errors import DatabaseLockedError +from errors import DatabaseROError +from errors import NonexistantObjectError +from alot.db import DB_ENC + + +class FillPipeProcess(multiprocessing.Process): + def __init__(self, it, pipe, fun=(lambda x: x)): + multiprocessing.Process.__init__(self) + self.it = it + self.pipe = pipe[1] + self.fun = fun + + def run(self): + for a in self.it: + self.pipe.send(self.fun(a)) + self.pipe.close() + + +class DBManager(object): + """ + Keeps track of your index parameters, maintains a write-queue and + lets you look up threads and messages directly to the persistent wrapper + classes. + """ + _sort_orders = { + 'oldest_first': notmuch.database.Query.SORT.OLDEST_FIRST, + 'newest_first': notmuch.database.Query.SORT.NEWEST_FIRST, + 'unsorted': notmuch.database.Query.SORT.UNSORTED, + 'message_id': notmuch.database.Query.SORT.MESSAGE_ID, + } + """constants representing sort orders""" + + def __init__(self, path=None, ro=False): + """ + :param path: absolute path to the notmuch index + :type path: str + :param ro: open the index in read-only mode + :type ro: bool + """ + self.ro = ro + self.path = path + self.writequeue = deque([]) + self.processes = [] + + def flush(self): + """ + write out all queued write-commands in order, each one in a separate + :meth:`atomic ` transaction. + + If this fails the current action is rolled back, stays in the write + queue and an exception is raised. + You are responsible to retry flushing at a later time if you want to + ensure that the cached changes are applied to the database. + + :exception: :exc:`~errors.DatabaseROError` if db is opened read-only + :exception: :exc:`~errors.DatabaseLockedError` if db is locked + """ + if self.ro: + raise DatabaseROError() + if self.writequeue: + # read notmuch's config regarding imap flag synchronization + sync = settings.get_notmuch_setting('maildir', 'synchronize_flags') + + # go through writequeue entries + while self.writequeue: + current_item = self.writequeue.popleft() + logging.debug('write-out item: %s' % str(current_item)) + + # watch out for notmuch errors to re-insert current_item + # to the queue on errors + try: + # the first two coordinants are cnmdname and post-callback + cmd, afterwards = current_item[:2] + logging.debug('cmd created') + + # aquire a writeable db handler + try: + mode = Database.MODE.READ_WRITE + db = Database(path=self.path, mode=mode) + except NotmuchError: + raise DatabaseLockedError() + logging.debug('got write lock') + + # make this a transaction + db.begin_atomic() + logging.debug('got atomic') + + if cmd == 'add': + logging.debug('add') + path, tags = current_item[2:] + msg, status = db.add_message(path, + sync_maildir_flags=sync) + logging.debug('added msg') + msg.freeze() + logging.debug('freeze') + for tag in tags: + msg.add_tag(tag.encode(DB_ENC), + sync_maildir_flags=sync) + logging.debug('added tags ') + msg.thaw() + logging.debug('thaw') + + elif cmd == 'remove': + path = current_item[2] + db.remove_message(path) + + else: # tag/set/untag + querystring, tags = current_item[2:] + query = db.create_query(querystring) + for msg in query.search_messages(): + msg.freeze() + if cmd == 'tag': + for tag in tags: + msg.add_tag(tag.encode(DB_ENC), + sync_maildir_flags=sync) + if cmd == 'set': + msg.remove_all_tags() + for tag in tags: + msg.add_tag(tag.encode(DB_ENC), + sync_maildir_flags=sync) + elif cmd == 'untag': + for tag in tags: + msg.remove_tag(tag.encode(DB_ENC), + sync_maildir_flags=sync) + msg.thaw() + + logging.debug('ended atomic') + # end transaction and reinsert queue item on error + if db.end_atomic() != notmuch.STATUS.SUCCESS: + raise DatabaseError('end_atomic failed') + logging.debug('ended atomic') + + # close db + db.close() + logging.debug('closed db') + + # call post-callback + if callable(afterwards): + logging.debug(str(afterwards)) + afterwards() + logging.debug('called callback') + + # re-insert item to the queue upon Xapian/NotmuchErrors + except (XapianError, NotmuchError) as e: + logging.exception(e) + self.writequeue.appendleft(current_item) + raise DatabaseError(unicode(e)) + except DatabaseLockedError as e: + logging.debug('index temporarily locked') + self.writequeue.appendleft(current_item) + raise e + logging.debug('flush finished') + + def kill_search_processes(self): + """ + terminate all search processes that originate from + this managers :meth:`get_threads`. + """ + for p in self.processes: + p.terminate() + self.processes = [] + + def tag(self, querystring, tags, afterwards=None, remove_rest=False): + """ + add tags to messages matching `querystring`. + This appends a tag operation to the write queue and raises + :exc:`~errors.DatabaseROError` if in read only mode. + + :param querystring: notmuch search string + :type querystring: str + :param tags: a list of tags to be added + :type tags: list of str + :param afterwards: callback that gets called after successful + application of this tagging operation + :type afterwards: callable + :param remove_rest: remove tags from matching messages before tagging + :type remove_rest: bool + :exception: :exc:`~errors.DatabaseROError` + + .. note:: + This only adds the requested operation to the write queue. + You need to call :meth:`DBManager.flush` to actually write out. + """ + if self.ro: + raise DatabaseROError() + if remove_rest: + self.writequeue.append(('set', afterwards, querystring, tags)) + else: + self.writequeue.append(('tag', afterwards, querystring, tags)) + + def untag(self, querystring, tags, afterwards=None): + """ + removes tags from messages that match `querystring`. + This appends an untag operation to the write queue and raises + :exc:`~errors.DatabaseROError` if in read only mode. + + :param querystring: notmuch search string + :type querystring: str + :param tags: a list of tags to be added + :type tags: list of str + :param afterwards: callback that gets called after successful + application of this tagging operation + :type afterwards: callable + :exception: :exc:`~errors.DatabaseROError` + + .. note:: + This only adds the requested operation to the write queue. + You need to call :meth:`DBManager.flush` to actually write out. + """ + if self.ro: + raise DatabaseROError() + self.writequeue.append(('untag', afterwards, querystring, tags)) + + def count_messages(self, querystring): + """returns number of messages that match `querystring`""" + return self.query(querystring).count_messages() + + def count_threads(self, querystring): + """returns number of threads that match `querystring`""" + return self.query(querystring).count_threads() + + def search_thread_ids(self, querystring): + """ + returns the ids of all threads that match the `querystring` + This copies! all integer thread ids into an new list. + + :returns: list of str + """ + + return self.query_threaded(querystring) + + def _get_notmuch_thread(self, tid): + """returns :class:`notmuch.database.Thread` with given id""" + query = self.query('thread:' + tid) + try: + return query.search_threads().next() + except StopIteration: + errmsg = 'no thread with id %s exists!' % tid + raise NonexistantObjectError(errmsg) + + def get_thread(self, tid): + """returns :class:`Thread` with given thread id (str)""" + return Thread(self, self._get_notmuch_thread(tid)) + + def _get_notmuch_message(self, mid): + """returns :class:`notmuch.database.Message` with given id""" + mode = Database.MODE.READ_ONLY + db = Database(path=self.path, mode=mode) + try: + return db.find_message(mid) + except: + errmsg = 'no message with id %s exists!' % mid + raise NonexistantObjectError(errmsg) + + def get_message(self, mid): + """returns :class:`Message` with given message id (str)""" + return Message(self, self._get_notmuch_message(mid)) + + def get_all_tags(self): + """ + returns all tagsstrings used in the database + :rtype: list of str + """ + db = Database(path=self.path) + return [t for t in db.get_all_tags()] + + def async(self, cbl, fun): + """ + return a pair (pipe, process) so that the process writes + `fun(a)` to the pipe for each element `a` in the iterable returned + by the callable `cbl`. + + :param cbl: a function returning something iterable + :type cbl: callable + :param fun: an unary translation function + :type fun: callable + :rtype: (:class:`multiprocessing.Pipe`, + :class:`multiprocessing.Process`) + """ + pipe = multiprocessing.Pipe(False) + receiver, sender = pipe + process = FillPipeProcess(cbl(), pipe, fun) + process.start() + self.processes.append(process) + # closing the sending end in this (receiving) process guarantees + # that here the apropriate EOFError is raised upon .recv in the walker + sender.close() + return receiver, process + + def get_threads(self, querystring, sort='newest_first'): + """ + asynchronously look up thread ids matching `querystring`. + + :param querystring: The query string to use for the lookup + :type querystring: str. + :param sort: Sort order. one of ['oldest_first', 'newest_first', + 'message_id', 'unsorted'] + :type query: str + :returns: a pipe together with the process that asynchronously + writes to it. + :rtype: (:class:`multiprocessing.Pipe`, + :class:`multiprocessing.Process`) + """ + assert sort in self._sort_orders.keys() + q = self.query(querystring) + q.set_sort(self._sort_orders[sort]) + return self.async(q.search_threads, (lambda a: a.get_thread_id())) + + def query(self, querystring): + """ + creates :class:`notmuch.Query` objects on demand + + :param querystring: The query string to use for the lookup + :type query: str. + :returns: :class:`notmuch.Query` -- the query object. + """ + mode = Database.MODE.READ_ONLY + db = Database(path=self.path, mode=mode) + return db.create_query(querystring) + + def add_message(self, path, tags=[], afterwards=None): + """ + Adds a file to the notmuch index. + + :param path: path to the file + :type path: str + :param tags: tagstrings to add + :type tags: list of str + :param afterwards: callback to trigger after adding + :type afterwards: callable or None + """ + if self.ro: + raise DatabaseROError() + self.writequeue.append(('add', afterwards, path, tags)) + + def remove_message(self, message, afterwards=None): + """ + Remove a message from the notmuch index + + :param message: message to remove + :type message: :class:`Message` + :param afterwards: callback to trigger after removing + :type afterwards: callable or None + """ + if self.ro: + raise DatabaseROError() + path = message.get_filename() + self.writequeue.append(('remove', afterwards, path)) diff --git a/alot/init.py b/alot/init.py index 732a674d..449eea30 100755 --- a/alot/init.py +++ b/alot/init.py @@ -7,7 +7,7 @@ import os from alot.settings import settings from alot.settings.errors import ConfigError -from alot.db import DBManager +from alot.db.manager import DBManager from alot.ui import UI import alot.commands as commands from alot.commands import * -- cgit v1.2.3 From 7dc706404c51d828085b9d975a44c993a9f80129 Mon Sep 17 00:00:00 2001 From: Patrick Totzke Date: Sun, 5 Aug 2012 14:39:50 +0100 Subject: cleanup: make Thread and Message available in alot.db --- alot/db/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/alot/db/__init__.py b/alot/db/__init__.py index ae067ce3..f61142ea 100644 --- a/alot/db/__init__.py +++ b/alot/db/__init__.py @@ -2,4 +2,6 @@ # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file +from thread import Thread +from message import Message DB_ENC = 'UTF-8' -- cgit v1.2.3 From fd18448d298d2a741303083cdecd7bb21883a0f4 Mon Sep 17 00:00:00 2001 From: Patrick Totzke Date: Sun, 5 Aug 2012 14:40:19 +0100 Subject: doc: update api docs wrt moved parts --- alot/widgets/globals.py | 2 +- docs/source/api/database.rst | 18 +++++++++--------- docs/source/api/interface.rst | 24 +++++++++++++++++++++++- docs/source/api/settings.rst | 39 ++++++++++++++++++++++----------------- 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/alot/widgets/globals.py b/alot/widgets/globals.py index 96fec312..210d4b54 100644 --- a/alot/widgets/globals.py +++ b/alot/widgets/globals.py @@ -3,7 +3,7 @@ # For further details see the COPYING file """ -This contains alot-specific :ref:`urwid.Widgets` used in more than one mode. +This contains alot-specific :class:`urwid.Widget` used in more than one mode. """ import urwid diff --git a/docs/source/api/database.rst b/docs/source/api/database.rst index 71fa50ef..ff66fd3e 100644 --- a/docs/source/api/database.rst +++ b/docs/source/api/database.rst @@ -5,23 +5,23 @@ Email Database The python bindings to libnotmuch define :class:`notmuch.Thread` and :class:`notmuch.Message`, which unfortunately are very fragile. -Alot defines the wrapper classes :class:`Thread` and :class:`~alot.db.message.Message` that -use an :class:`DBManager` instance to transparently provide persistent objects. +Alot defines the wrapper classes :class:`alot.db.Thread` and :class:`alot.db.Message` that +use an :class:`manager.DBManager` instance to transparently provide persistent objects. -:class:`~alot.db.message.Message` moreover contains convenience methods +:class:`alot.db.Message` moreover contains convenience methods to extract information about the message like reformated header values, a summary, decoded and interpreted body text and a list of :class:`Attachments `. -The central :class:`~alot.ui.UI` instance carries around a :class:`DBManager` object that -is used for any lookups or modifications of the email base. :class:`DBManager` can -directly look up :class:`Thread` and :class:`~alot.db.message.Message` objects and is able to +The central :class:`~alot.ui.UI` instance carries around a :class:`~manager.DBManager` object that +is used for any lookups or modifications of the email base. :class:`~manager.DBManager` can +directly look up :class:`Thread` and :class:`~alot.db.Message` objects and is able to postpone/cache/retry writing operations in case the Xapian index is locked by another process. Database Manager ----------------- -.. autoclass:: DBManager +.. autoclass:: alot.db.manager.DBManager :members: @@ -41,11 +41,11 @@ Errors Wrapper ------- -.. autoclass:: alot.db.thread.Thread +.. autoclass:: alot.db.Thread :members: -.. autoclass:: alot.db.message.Message +.. autoclass:: alot.db.Message :members: diff --git a/docs/source/api/interface.rst b/docs/source/api/interface.rst index f9afb7ec..d2294e26 100644 --- a/docs/source/api/interface.rst +++ b/docs/source/api/interface.rst @@ -85,7 +85,29 @@ Widgets What follows is a list of the non-standard urwid widgets used in alot. Some of them respect :doc:`user settings `, themes in particular. -.. automodule:: alot.widgets +utils +````` +.. automodule:: alot.widgets.utils + :members: + +globals +``````` +.. automodule:: alot.widgets.globals + :members: + +bufferlist +`````````` +.. automodule:: alot.widgets.bufferlist + :members: + +search +`````` +.. automodule:: alot.widgets.search + :members: + +thread +`````` +.. automodule:: alot.widgets.thread :members: Completion diff --git a/docs/source/api/settings.rst b/docs/source/api/settings.rst index 2cb7ea74..4f8dc455 100644 --- a/docs/source/api/settings.rst +++ b/docs/source/api/settings.rst @@ -1,24 +1,29 @@ User Settings ============= -.. module:: alot.settings +.. module:: alot.settings.manager + +Alot sets up a :class:`SettingsManager` to access user settings +defined in different places uniformly. +There are four types of user settings: + ++------------------------------------+----------------------------------+---------------------------------------------+ +| what? | location | accessible via | ++====================================+==================================+=============================================+ +| alot config | :file:`~/.config/alot/config` | :meth:`SettingsManager.get` | +| | or given by command option `-c`. | | ++------------------------------------+----------------------------------+---------------------------------------------+ +| hooks -- user provided python code | :file:`~/.config/alot/hooks.py` | :meth:`SettingsManager.get_hook` | +| | or as given by the `hooksfile` | | +| | config value | | ++------------------------------------+----------------------------------+---------------------------------------------+ +| notmuch config | :file:`~/.notmuchrc` | :meth:`SettingsManager.get_notmuch_setting` | +| | or given by command option `-n` | | ++------------------------------------+----------------------------------+---------------------------------------------+ +| mailcap -- defines shellcommands | :file:`~/.mailcap` | :meth:`SettingsManager.mailcap_find_match` | +| to handle mime types | (:file:`/etc/mailcap`) | | ++------------------------------------+----------------------------------+---------------------------------------------+ -There are four types of user settings: notmuchs and alot's config -files, the hooks-file for user provided python code and the mailcap, -defining shell comands as handlers for files of certain mime types. -Alot sets up :class:`SettingsManager` objects to access these user settings uniformly. - -MIME handlers can be looked up via :meth:`SettingsManager.get_mime_handler`, -config values of alot and notmuch's config are accessible using -:meth:`SettingsManager.get` and :meth:`SettingsManager.get_notmuch_setting`. -These methods return either None or the requested value typed as indicated in -the spec files :file:`alot/defaults/*spec`. - -Hooks can be looked up via :meth:`SettingsManager.get_hook`. -They are user defined callables that expect to be called with the following parameters: - - :ui: :class:`~alot.ui.UI` -- the initialized main component - :dbm: :class:`~alot.db.DBManager` -- :obj:`ui.dbman` Settings Manager ---------------- -- cgit v1.2.3 From 2e414327e80fe59a55b9a5676bf38bebec604fed Mon Sep 17 00:00:00 2001 From: Patrick Totzke Date: Fri, 10 Aug 2012 12:50:09 +0100 Subject: doc: rtfd Mock class hack this makes autodoc of urwid decendants work again. cheers to teythoon closes #496 --- docs/source/conf.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index a415a655..4dbe0b5b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,8 +19,12 @@ class Mock(object): def __getattr__(self, name): return Mock() if name not in ('__file__', '__path__') else '/dev/null' -MOCK_MODULES = ['notmuch', 'notmuch.globals', - 'twisted', 'twisted.internet', +class MockModule(object): + @classmethod + def __getattr__(self, name): + return Mock if name not in ('__file__', '__path__') else '/dev/null' + +MOCK_MODULES = ['twisted', 'twisted.internet', 'twisted.internet.defer', 'twisted.python', 'twisted.python.failure', @@ -29,7 +33,10 @@ MOCK_MODULES = ['notmuch', 'notmuch.globals', 'magic', 'gpgme', 'argparse'] +MOCK_DIRTY = ['notmuch'] for mod_name in MOCK_MODULES: + sys.modules[mod_name] = MockModule() +for mod_name in MOCK_DIRTY: sys.modules[mod_name] = Mock() # end of readthedocs.org hack -- cgit v1.2.3