# 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 datetime import datetime, timedelta import email from functools import cached_property import importlib.util import itertools import logging import mailcap import os import re from configobj import ConfigObj, Section from urwid import AttrSpec from .errors import ConfigError, NoMatchingAccount from .utils import read_config from .theme import Theme from ..account import SendmailAccount from ..addressbook.abook import AbookAddressBook from ..addressbook.external import ExternalAddressbook from ..helper import get_xdg_env from ..utils import configobj as checks DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults') DATA_DIRS = get_xdg_env('XDG_DATA_DIRS', '/usr/local/share:/usr/share').split(':') def _resolve_att(a, fallback): """ replace '' and 'default' by fallback values """ if a is None: return fallback if a.background in ['default', '']: bg = fallback.background else: bg = a.background if a.foreground in ['default', '']: fg = fallback.foreground else: fg = a.foreground return AttrSpec(fg, bg) def _pretty_datetime(d): """ translates :class:`datetime` `d` to a "sup-style" human readable string. >>> now = datetime.now() >>> now.strftime('%c') 'Sat 31 Mar 2012 14:47:26 ' >>> _pretty_datetime(now) 'just now' >>> _pretty_datetime(now - timedelta(minutes=1)) '1min ago' >>> _pretty_datetime(now - timedelta(hours=5)) '5h ago' >>> _pretty_datetime(now - timedelta(hours=12)) '02:54am' >>> _pretty_datetime(now - timedelta(days=1)) 'yest 02pm' >>> _pretty_datetime(now - timedelta(days=2)) 'Thu 02pm' >>> _pretty_datetime(now - timedelta(days=7)) 'Mar 24' >>> _pretty_datetime(now - timedelta(days=356)) 'Apr 2011' """ ampm = d.strftime('%p').lower() if len(ampm): hourfmt = '%I' + ampm hourminfmt = '%I:%M' + ampm else: hourfmt = '%Hh' hourminfmt = '%H:%M' now = datetime.now() today = now.date() if d > now + timedelta(seconds = 59): string = 'future' elif d.date() == today or d > now - timedelta(hours=6): delta = datetime.now() - d if delta.seconds < 60: string = 'just now' elif delta.seconds < 3600: string = '%dmin ago' % (delta.seconds // 60) elif delta.seconds < 6 * 3600: string = '%dh ago' % (delta.seconds // 3600) else: string = d.strftime(hourminfmt) elif d.date() == today - timedelta(1): string = d.strftime('yest ' + hourfmt) elif d.date() > today - timedelta(7): string = d.strftime('%a ' + hourfmt) elif d.year != today.year: string = d.strftime('%b %Y') else: string = d.strftime('%b %d') return string class SettingsManager: """Organizes user settings""" def __init__(self): self.hooks = None self._mailcaps = mailcap.getcaps() self._notmuchconfig = None self._theme = None self._accounts = None self._accountmap = None self._notmuchconfig = None self._config = ConfigObj() self._bindings = None self._config_paths = [] def reload(self): """Reload notmuch and alot config files""" self.read_notmuch_config(self._notmuchconfig.filename) self.read_config(self._config_paths) def read_notmuch_config(self, path): """ parse notmuch's config file :param path: path to notmuch's config file :type path: str """ spec = os.path.join(DEFAULTSPATH, 'notmuch.rc.spec') self._notmuchconfig = read_config([path], spec) def _update_bindings(self, newbindings): assert isinstance(newbindings, Section) self._bindings = ConfigObj(os.path.join(DEFAULTSPATH, 'default.bindings')) self._bindings.merge(newbindings) def read_config(self, paths): """ parse alot's config file :param paths: list of config file paths :type path: list(str) """ spec = os.path.join(DEFAULTSPATH, 'alot.rc.spec') newconfig = read_config(paths, spec, report_extra=True, checks={ 'mail_container': checks.mail_container, 'force_list': checks.force_list, 'align': checks.align_mode, 'attrtriple': checks.attr_triple, 'gpg_key_hint': checks.gpg_key}) self._config.merge(newconfig) self._config.walk(self._expand_config_values) self._config_paths = paths hooks_path = os.path.expanduser(self._config.get('hooksfile')) if os.path.isfile(hooks_path): try: spec = importlib.util.spec_from_file_location('hooks', hooks_path) self.hooks = importlib.util.module_from_spec(spec) spec.loader.exec_module(self.hooks) except Exception as e: self.hooks = None raise ConfigError('Error loading hooks: %s' % str(e)) from e else: logging.info('Hooks file "%s" does not exist', hooks_path) if 'bindings' in newconfig: self._update_bindings(newconfig['bindings']) tempdir = self._config.get('template_dir') logging.debug('template directory: `%s`' % tempdir) # validate edit_headers_whitelist/blacklist, which depend on each other wl = self._config.get('edit_headers_whitelist') bl = self._config.get('edit_headers_blacklist') if (wl == ['*']) == (bl == ['*']): raise ConfigError('Exactly one of "edit_headers_whitelist"', '"edit_headers_blacklist" must be "*"') if ('*' in wl and len(wl) > 1) or ('*' in bl and len(bl) > 1): raise ConfigError('When "*" is used in "edit_headers_whitelist"/' '"edit_headers_blacklist", it must be the only item.') # themes themestring = newconfig['theme'] themes_dir = self._config.get('themes_dir') logging.debug('themes directory: `%s`' % themes_dir) # if config contains theme string use that data_dirs = [os.path.join(d, 'alot/themes') for d in DATA_DIRS] if themestring: # This is a python for/else loop # https://docs.python.org/3/reference/compound_stmts.html#for # # tl/dr; If the loop loads a theme it breaks. If it doesn't break, # then it raises a ConfigError. for dir_ in itertools.chain([themes_dir], data_dirs): theme_path = os.path.join(dir_, themestring) if not os.path.exists(os.path.expanduser(theme_path)): logging.warning('Theme `%s` does not exist.', theme_path) else: try: self._theme = Theme(theme_path) except ConfigError as e: raise ConfigError('Theme file `%s` failed ' 'validation:\n%s' % (theme_path, e)) else: break else: raise ConfigError('Could not find theme {}, see log for more ' 'information'.format(themestring)) # if still no theme is set, resort to default if self._theme is None: theme_path = os.path.join(DEFAULTSPATH, 'default.theme') self._theme = Theme(theme_path) self._accounts = self._parse_accounts(self._config) self._accountmap = self._account_table(self._accounts) @staticmethod def _expand_config_values(section, key): """ Walker function for ConfigObj.walk Applies expand_environment_and_home to all configuration values that are strings (or strings that are elements of tuples/lists) :param section: as passed by ConfigObj.walk :param key: as passed by ConfigObj.walk """ def expand_environment_and_home(value): """ Expands environment variables and the home directory (~). $FOO and ${FOO}-style environment variables are expanded, if they exist. If they do not exist, they are left unchanged. The exception are the following $XDG_* variables that are expanded to fallback values, if they are empty or not set: $XDG_CONFIG_HOME $XDG_CACHE_HOME :param value: configuration string :type value: str """ xdg_vars = {'XDG_CONFIG_HOME': '~/.config', 'XDG_CACHE_HOME': '~/.cache'} for xdg_name, fallback in xdg_vars.items(): if xdg_name in value: xdg_value = get_xdg_env(xdg_name, fallback) value = value.replace('$%s' % xdg_name, xdg_value)\ .replace('${%s}' % xdg_name, xdg_value) return os.path.expanduser(os.path.expandvars(value)) value = section[key] if isinstance(value, str): section[key] = expand_environment_and_home(value) elif isinstance(value, (list, tuple)): new = list() for item in value: if isinstance(item, str): new.append(expand_environment_and_home(item)) else: new.append(item) section[key] = new @staticmethod def _parse_accounts(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].items()) # 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: ef = abook['shellcommand_external_filtering'] args['abook'] = ExternalAddressbook( cmd, regexp, external_filtering=ef) 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, ignorecase=abook['ignorecase']) else: del args['abook'] cmd = args['sendmail_command'] del args['sendmail_command'] newacc = SendmailAccount(cmd, **args) accounts.append(newacc) return accounts @staticmethod def _account_table(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 identifies :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_quote_theming(self): colours = int(self._config.get('colourmode')) return self._theme.get_quote_theming(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('^{}$'.format(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: return getattr(self.hooks, key, None) return None def get_mapped_input_keysequences(self, mode='global', prefix=''): # get all bindings in this mode globalmaps, modemaps = self.get_keybindings(mode) candidates = list(globalmaps.keys()) + list(modemaps.keys()) if prefix is not None: prefixes = prefix + ' ' cand = [c for c in candidates if c.startswith(prefixes)] if prefix in candidates: candidates = cand + [prefix] else: candidates = cand return candidates def get_keybindings(self, mode): """look up keybindings from `MODE-maps` sections :param mode: mode identifier :type mode: str :returns: dictionaries of key-cmd for global and specific mode :rtype: 2-tuple of dicts """ globalmaps, modemaps = {}, {} bindings = self._bindings # get bindings for mode `mode` # retain empty assignations to silence corresponding global mappings if mode in bindings.sections: for key in bindings[mode].scalars: value = bindings[mode][key] if isinstance(value, list): value = ','.join(value) modemaps[key] = value # get global bindings # ignore the ones already mapped in mode bindings for key in bindings.scalars: if key not in modemaps: value = bindings[key] if isinstance(value, list): value = ','.join(value) if value and value != '': globalmaps[key] = value # get rid of empty commands left in mode bindings for k, v in list(modemaps.items()): if not v: del modemaps[k] return globalmaps, modemaps 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 else: # to be sure it isn't mapped globally cmdline = None # Workaround for ConfigObj misbehaviour. cf issue #500 # this ensures that we get at least strings only as commandlines if isinstance(cmdline, list): cmdline = ','.join(cmdline) return cmdline def get_accounts(self): """ returns known accounts :rtype: list of :class:`Account` """ return self._accounts def account_matching_address(self, address, return_default=False): """returns :class:`Account` for a given email address (str) :param str address: address to look up. A realname part will be ignored. :param bool return_default: If True and no address can be found, then the default account wil be returned. :rtype: :class:`Account` :raises ~alot.settings.errors.NoMatchingAccount: If no account can be found. This includes if return_default is True and there are no accounts defined. """ _, address = email.utils.parseaddr(address) for account in self.get_accounts(): if account.matches_address(address): return account if return_default: try: return self.get_accounts()[0] except IndexError: # Fall through pass raise NoMatchingAccount def get_main_addresses(self): """returns addresses of known accounts without its aliases""" return [a.address for a in self._accounts] def get_addressbooks(self, order=None, append_remaining=True): """returns list of all defined :class:`AddressBook` objects""" order = order or [] 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 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:`_pretty_datetime` as fallback """ fixed_format = self.get('timestamp_format') if fixed_format: rep = d.strftime(fixed_format) else: format_hook = self.get_hook('timestamp_format') if format_hook: rep = format_hook(d) else: rep = _pretty_datetime(d) return rep def _make_trans_table(self, tab_width, replace_newline): table = {} # remove C1 control codes for i in range(0x80, 0xa0): table[i] = None # replace C0 control codes with characters for their graphical # representations i.e. "control pictures", which start at U+2400 for i in range(ord(' ')): table[i] = 0x2400 + i # "delete" character table[0x7f] = 0x2421 # delete LF table[ord('\r')] = None if replace_newline: table[ord('\n')] = ' ' else: # handle CR normally del table[ord('\n')] # expand tabs table[ord('\t')] = ' ' * tab_width # various Unicode zero-width spaces # mongolian vowel separator table[0x180e] = None # zero width space table[0x200b] = None # zero width non-joiner table[0x200c] = None # zero width joiner table[0x200d] = None # word joiner table[0x2060] = None # BOM table[0xfeff] = None return table @cached_property def sanitize_text_table(self): return self._make_trans_table(self.get('tabwidth'), False) @cached_property def sanitize_header_table(self): return self._make_trans_table(1, True)