[Python-modules-commits] [afew] 01/02: New upstream version 0.0+git2016.02.29.b19a88f

Free Ekanayaka freee at moszumanska.debian.org
Fri Dec 23 10:19:31 UTC 2016


This is an automated email from the git hooks/post-receive script.

freee pushed a commit to branch master
in repository afew.

commit 3eff53f1a1badccd800ced58e539eebe86ab212b
Author: Free Ekanayaka <freee at debian.org>
Date:   Fri Dec 23 00:02:23 2016 +0000

    New upstream version 0.0+git2016.02.29.b19a88f
---
 .gitignore                             |   7 +
 NEWS.md                                |  23 +++
 README.md                              | 256 ++++++++++++++++++++++++++++++++
 afew/DBACL.py                          | 112 ++++++++++++++
 afew/Database.py                       | 199 +++++++++++++++++++++++++
 afew/FilterRegistry.py                 |  77 ++++++++++
 afew/MailMover.py                      | 139 ++++++++++++++++++
 afew/NotmuchSettings.py                |  36 +++++
 afew/Settings.py                       | 109 ++++++++++++++
 afew/__init__.py                       |  18 +++
 afew/commands.py                       | 205 ++++++++++++++++++++++++++
 afew/configparser.py                   |  39 +++++
 afew/defaults/afew.config              |  50 +++++++
 afew/files.py                          | 209 ++++++++++++++++++++++++++
 afew/filters/ArchiveSentMailsFilter.py |  31 ++++
 afew/filters/BaseFilter.py             | 126 ++++++++++++++++
 afew/filters/ClassifyingFilter.py      |  41 ++++++
 afew/filters/FolderNameFilter.py       |  81 +++++++++++
 afew/filters/HeaderMatchingFilter.py   |  47 ++++++
 afew/filters/InboxFilter.py            |  40 +++++
 afew/filters/KillThreadsFilter.py      |  31 ++++
 afew/filters/ListMailsFilter.py        |  29 ++++
 afew/filters/SentMailsFilter.py        |  96 ++++++++++++
 afew/filters/SpamFilter.py             |  33 +++++
 afew/filters/__init__.py               |  28 ++++
 afew/main.py                           |  86 +++++++++++
 afew/tests/__init__.py                 |  15 ++
 afew/tests/test_settings.py            |  58 ++++++++
 afew/utils.py                          | 138 ++++++++++++++++++
 docs/Makefile                          | 130 +++++++++++++++++
 docs/source/_static/.keep              |   0
 docs/source/_templates/.keep           |   0
 docs/source/classification.rst         |  57 ++++++++
 docs/source/commandline.rst            | 141 ++++++++++++++++++
 docs/source/conf.py                    | 259 +++++++++++++++++++++++++++++++++
 docs/source/configuration.rst          | 160 ++++++++++++++++++++
 docs/source/extending.rst              |  51 +++++++
 docs/source/filters.rst                | 238 ++++++++++++++++++++++++++++++
 docs/source/implementation.rst         |  45 ++++++
 docs/source/index.rst                  |  43 ++++++
 docs/source/installation.rst           |  35 +++++
 docs/source/move_mode.rst              | 103 +++++++++++++
 docs/source/quickstart.rst             |  84 +++++++++++
 setup.py                               |  54 +++++++
 44 files changed, 3759 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9869866
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+*.py[co]
+/dist
+/build
+bin/
+include/
+lib/
+/afew.egg-info
diff --git a/NEWS.md b/NEWS.md
new file mode 100644
index 0000000..9fb7308
--- /dev/null
+++ b/NEWS.md
@@ -0,0 +1,23 @@
+afew x.x (xxxx-xx-xx)
+=====================
+
+Filter behaviour change
+
+  As of commit d98a0cd0d1f37ee64d03be75e75556cff9f32c29, the ListMailsFilter
+  does not add a tag named `list-id `anymore, but a new one called
+  `lists/<list-id>`.
+
+afew 0.1pre (2012-02-10)
+========================
+
+Configuration format change
+
+  Previously the values for configuration entries with the key `tags`
+  were interpreted as a whitespace delimited list of strings. As of
+  commit e4ec3ced16cc90c3e9c738630bf0151699c4c087 those entries are
+  split at semicolons (';').
+
+  This changes the semantic of the configuration file and affects
+  everyone who uses filter rules that set or remove more than one tag
+  at once. Please inspect your configuration files and adjust them if
+  necessary.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4d1dd6e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,256 @@
+About
+=====
+
+afew is an initial tagging script for notmuch mail:
+
+* http://notmuchmail.org/
+* http://notmuchmail.org/initial_tagging/
+
+Its basic task is to provide automatic tagging each time new mail is registered
+with notmuch. In a classic setup, you might call it after 'notmuch new' in an
+offlineimap post sync hook.
+
+In addition to more elementary features such as adding tags based on email
+headers or maildir folders, handling killed threads and spam, it can do some
+heavy magic in order to /learn/ how to initially tag your mails based on their
+content.
+
+In move mode, afew will move mails between maildir folders according to
+configurable rules that can contain arbitrary notmuch queries to match against
+any searchable attributes.
+
+fyi: afew plays nicely with alot, a GUI for notmuch mail ;)
+
+* https://github.com/pazz/alot
+
+
+Current NEWS
+============
+
+afew is quite young, so expect a few user visible API or configuration
+format changes, though I'll try to minimize such disruptive events.
+
+Please keep an eye on NEWS.md for important news.
+
+
+Features
+========
+
+* text classification, magic tags aka the mailing list without server
+* spam handling (flush all tags, add spam)
+* killed thread handling
+* tags posts to lists with `lists`, `$list-id`
+* autoarchives mails sent from you
+* catchall -> remove `new`, add `inbox`
+* can operate on new messages [default], `--all` messages or on custom
+  query results
+* can move mails based on arbitrary notmuch queries, so your sorting
+  may show on your traditional mail client (well, almost ;))
+* has a `--dry-run` mode for safe testing
+* works with python 2.7, 3.1 and 3.2
+
+
+Installation
+============
+
+You'll need dbacl for the text classification:
+
+    # aptitude install dbacl
+
+And I'd like to suggest to install afew as your unprivileged user.
+If you do, make sure `~/.local/bin` is in your path.
+
+    $ python setup.py install --prefix=~/.local
+    $ mkdir -p ~/.config/afew ~/.local/share/afew/categories
+
+
+Configuration
+=============
+
+Make sure that `~/.notmuch-config` reads:
+
+```
+[new]
+tags=new
+```
+
+Put a list of filters into `~/.config/afew/config`:
+
+```
+# This is the default filter chain
+[SpamFilter]
+[ClassifyingFilter]
+[KillThreadsFilter]
+[ListMailsFilter]
+[ArchiveSentMailsFilter]
+[InboxFilter]
+```
+
+And configure rules to sort mails on your disk, if you want:
+
+```
+[MailMover]
+folders = INBOX Junk
+max_age = 15
+
+# rules
+INBOX = 'tag:spam':Junk 'NOT tag:inbox':Archive
+Junk = 'NOT tag:spam AND tag:inbox':INBOX 'NOT tag:spam':Archive
+```
+
+
+Commandline help
+================
+
+```
+$ afew --help
+Usage: afew [options] [--] [query]
+
+Options:
+  -h, --help            show this help message and exit
+
+  Actions:
+    Please specify exactly one action (both update actions can be
+    specified simultaniously).
+
+    -t, --tag           run the tag filters
+    -l LEARN, --learn=LEARN
+                        train the category with the messages matching the
+                        given query
+    -u, --update        update the categories [requires no query]
+    -U, --update-reference
+                        update the reference category (takes quite some time)
+                        [requires no query]
+    -c, --classify      classify each message matching the given query (to
+                        test the trained categories)
+    -m, --move-mails    move mail files between maildir folders
+
+  Query modifiers:
+    Please specify either --all or --new or a query string. The default
+    query for the update actions is a random selection of
+    REFERENCE_SET_SIZE mails from the last REFERENCE_SET_TIMEFRAME days.
+
+    -a, --all           operate on all messages
+    -n, --new           operate on all new messages
+
+  General options:
+    -C NOTMUCH_CONFIG, --notmuch-config=NOTMUCH_CONFIG
+                        path to the notmuch configuration file [default:
+                        $NOTMUCH_CONFIG or ~/.notmuch-config]
+    -e ENABLE_FILTERS, --enable-filters=ENABLE_FILTERS
+                        filter classes to use, separated by ',' [default:
+                        filters specified in afew's config]
+    -d, --dry-run       don't change the db [default: False]
+    -R REFERENCE_SET_SIZE, --reference-set-size=REFERENCE_SET_SIZE
+                        size of the reference set [default: 1000]
+    -T DAYS, --reference-set-timeframe=DAYS
+                        do not use mails older than DAYS days [default: 30]
+    -v, --verbose       be more verbose, can be given multiple times
+```
+
+
+Boring stuff
+============
+
+Simulation
+----------
+Adding `--dry-run` to any `--tag` or `--sync-tags` action prevents
+modification of the notmuch db. Add some `-vv` goodness to see some
+action.
+
+
+Initial tagging
+---------------
+Basic tagging stuff requires no configuration, just run
+
+    $ afew --tag --new
+
+To do this automatically you can add the following hook into your
+~/.offlineimaprc:
+
+    postsynchook = ionice -c 3 chrt --idle 0 /bin/sh -c "notmuch new && afew --tag --new"
+
+
+Tag filters
+-----------
+Tag filters are plugin-like modules that encapsulate tagging
+functionality. There is a filter that handles the archiving of mails
+you sent, one that handles spam, one for killed threads, one for
+mailing list magic...
+
+The tag filter concept allows you to easily extend afew's tagging
+abilities by writing your own filters. Take a look at the default
+configuration file (`afew/defaults/afew.config`) for a list of
+available filters and how to enable filters and create customized
+filter types.
+
+
+Move mode
+---------
+
+To invoke afew in move mode, provide the `--move-mails` option on the
+command line.  Move mode will respect `--dry-run`, so throw in
+`--verbose` and watch what effects a real run would have.
+
+In move mode, afew will check all mails (or only recent ones) in the
+configured maildir folders, deciding whether they should be moved to
+another folder.
+
+The decision is based on rules defined in your config file. A rule is
+bound to a source folder and specifies a target folder into which a
+mail will be moved that is matched by an associated query.
+
+This way you will be able to transfer your sorting principles roughly
+to the classic folder based maildir structure understood by your
+traditional mail server. Tag your mails with notmuch, call afew
+`--move-mails` in an offlineimap presynchook and enjoy a clean inbox
+in your webinterface/GUI-client at work.
+
+For information on how to configure rules for move mode, what you can
+do with it and what you can't, please refer to `docs/move_mode`.
+
+
+The real deal
+=============
+
+Let's train on an existing tag `spam`:
+
+    $ afew --learn spam -- tag:spam
+
+Let's build the reference category. This is important to reduce the
+false positive rate. This may take a while...
+
+    $ afew --update-reference
+
+And now let's create a new tag from an arbitrary query result:
+
+    $ afew -vv --learn sourceforge -- sourceforge
+
+Let's see how good the classification is:
+
+    $ afew --classify -- tag:inbox and not tag:killed
+    Sergio López <slpml at sinrega.org> (2011-10-08) (bug-hurd inbox lists unread) --> no match
+    Patrick Totzke <reply+i-1840934-9a702d09342dca2b120126b26b008d0deea1731e at reply.github.com> (2011-10-08) (alot inbox lists) --> alot
+    [...]
+
+As soon as you trained some categories, afew will automatically
+tag your new mails using the classifier. If you want to disable this
+feature, either use the `--enable-filters` option to override the default
+set of filters or remove the files in your afew state dir:
+
+    $ ls ~/.local/share/afew/categories
+    alot juggling  reference_category  sourceforge  spam
+
+You need to update the category files periodically. I'd suggest to run
+
+    $ afew --update
+
+on a weekly and
+
+    $ afew --update-reference
+
+on a monthly basis.
+
+
+
+Have fun :)
diff --git a/afew/DBACL.py b/afew/DBACL.py
new file mode 100644
index 0000000..0fb82b8
--- /dev/null
+++ b/afew/DBACL.py
@@ -0,0 +1,112 @@
+# coding=utf-8
+from __future__ import print_function, absolute_import, unicode_literals
+
+#
+# Copyright (c) Justus Winter <4winter at informatik.uni-hamburg.de>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+import os
+import glob
+import logging
+import functools
+import subprocess
+
+class ClassificationError(Exception): pass
+class BackendError(ClassificationError): pass
+
+default_db_path = os.path.join(os.environ.get('XDG_DATA_HOME',
+                                              os.path.expanduser('~/.local/share')),
+                               'afew', 'categories')
+
+class Classifier(object):
+    reference_category = 'reference_category'
+
+    def __init__(self, categories, database_directory = default_db_path):
+        self.categories = set(categories)
+        self.database_directory = database_directory
+
+    def learn(self, category, texts):
+        pass
+
+    def classify(self, text):
+        pass
+
+class DBACL(Classifier):
+    def __init__(self, database_directory = default_db_path):
+        categories = glob.glob1(database_directory, '*')
+        super(DBACL, self).__init__(categories, database_directory)
+
+    sane_environ = {
+        key: value
+        for key, value in os.environ.items()
+        if not (
+            key.startswith('LC_') or
+            key == 'LANG' or
+            key == 'LANGUAGE'
+        )
+    }
+
+    def _call_dbacl(self, args, **kwargs):
+        command_line = ['dbacl', '-T', 'email'] + args
+        logging.debug('executing %r' % command_line)
+        return subprocess.Popen(
+            command_line,
+            shell = False,
+            stdin = subprocess.PIPE,
+            stdout = subprocess.PIPE,
+            stderr = subprocess.PIPE,
+            env = self.sane_environ,
+            **kwargs
+        )
+
+    def get_category_path(self, category):
+        return os.path.join(self.database_directory, category.replace('/', '_'))
+
+    def learn(self, category, texts):
+        process = self._call_dbacl(['-l', self.get_category_path(category)])
+
+        for text in texts:
+            process.stdin.write((text + '\n').encode('utf-8'))
+
+        process.stdin.close()
+        process.wait()
+
+        if process.returncode != 0:
+            raise BackendError('dbacl learning failed:\n%s' % process.stderr.read())
+
+    def classify(self, text):
+        if not self.categories:
+            raise ClassificationError('No categories defined')
+
+        categories = functools.reduce(list.__add__, [
+            ['-c', self.get_category_path(category)]
+            for category in self.categories
+        ], [])
+
+        process = self._call_dbacl(categories + ['-n'])
+        stdout, stderr = process.communicate(text.encode('utf-8'))
+
+        if len(stderr) == 0:
+            result = stdout.split()
+            scores = list()
+            while result:
+                category = result.pop(0).decode('utf-8', 'replace')
+                score = float(result.pop(0))
+                scores.append((category, score))
+            scores.sort(key = lambda category_score: category_score[1])
+        else:
+            raise BackendError('dbacl classification failed:\n%s' % stderr)
+
+        return scores
diff --git a/afew/Database.py b/afew/Database.py
new file mode 100644
index 0000000..4b38f0c
--- /dev/null
+++ b/afew/Database.py
@@ -0,0 +1,199 @@
+# coding=utf-8
+from __future__ import print_function, absolute_import, unicode_literals
+
+#
+# Copyright (c) Justus Winter <4winter at informatik.uni-hamburg.de>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+import time
+import logging
+
+import notmuch
+
+from .NotmuchSettings import notmuch_settings, get_notmuch_new_tags
+from .utils import extract_mail_body
+
+class Database(object):
+    '''
+    Convenience wrapper around `notmuch`.
+    '''
+
+    def __init__(self):
+        self.db_path = notmuch_settings.get('database', 'path')
+        self.handle = None
+
+    def __enter__(self):
+        '''
+        Implements the context manager protocol.
+        '''
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        '''
+        Implements the context manager protocol.
+        '''
+        self.close()
+
+    def open(self, rw=False, retry_for=180, retry_delay=1):
+        if rw:
+            if self.handle and self.handle.mode == notmuch.Database.MODE.READ_WRITE:
+                return self.handle
+
+            start_time = time.time()
+            while True:
+                try:
+                    self.handle = notmuch.Database(self.db_path,
+                                                   mode = notmuch.Database.MODE.READ_WRITE)
+                    break
+                except notmuch.NotmuchError:
+                    time_left = int(retry_for - (time.time() - start_time))
+
+                    if time_left <= 0:
+                        raise
+
+                    if time_left % 15 == 0:
+                        logging.debug('Opening the database failed. Will keep trying for another {} seconds'.format(time_left))
+
+                    time.sleep(retry_delay)
+        else:
+            if not self.handle:
+                self.handle = notmuch.Database(self.db_path)
+
+        return self.handle
+
+    def close(self):
+        '''
+        Closes the notmuch database if it has been opened.
+        '''
+        if self.handle:
+            self.handle.close()
+            self.handle = None
+
+    def do_query(self, query):
+        '''
+        Executes a notmuch query.
+
+        :param query: the query to execute
+        :type  query: str
+        :returns: the query result
+        :rtype:   :class:`notmuch.Query`
+        '''
+        logging.debug('Executing query %r' % query)
+        return notmuch.Query(self.open(), query)
+
+    def get_messages(self, query, full_thread = False):
+        '''
+        Get all messages mathing the given query.
+
+        :param query: the query to execute using :func:`Database.do_query`
+        :type  query: str
+        :param full_thread: return all messages from mathing threads
+        :type  full_thread: bool
+        :returns: an iterator over :class:`notmuch.Message` objects
+        '''
+        if not full_thread:
+            for message in self.do_query(query).search_messages():
+                yield message
+        else:
+            for thread in self.do_query(query).search_threads():
+                for message in self.walk_thread(thread):
+                    yield message
+
+
+    def mail_bodies_matching(self, *args, **kwargs):
+        '''
+        Filters each message yielded from
+        :func:`Database.get_messages` through
+        :func:`afew.utils.extract_mail_body`.
+
+        This functions accepts the same arguments as
+        :func:`Database.get_messages`.
+
+        :returns: an iterator over :class:`list` of :class:`str`
+        '''
+        query = self.get_messages(*args, **kwargs)
+        for message in query:
+            yield extract_mail_body(message)
+
+    def walk_replies(self, message):
+        '''
+        Returns all replies to the given message.
+
+        :param message: the message to start from
+        :type  message: :class:`notmuch.Message`
+        :returns: an iterator over :class:`notmuch.Message` objects
+        '''
+        yield message
+
+        # TODO: bindings are *very* unpythonic here... iterator *or* None
+        #       is a nono
+        replies = message.get_replies()
+        if replies != None:
+            for message in replies:
+                # TODO: yield from
+                for message in self.walk_replies(message):
+                    yield message
+
+    def walk_thread(self, thread):
+        '''
+        Returns all messages in the given thread.
+
+        :param message: the tread you are interested in
+        :type  message: :class:`notmuch.Thread`
+        :returns: an iterator over :class:`notmuch.Message` objects
+        '''
+        for message in thread.get_toplevel_messages():
+            # TODO: yield from
+            for message in self.walk_replies(message):
+                yield message
+
+    def add_message(self, path, sync_maildir_flags=False, new_mail_handler=None):
+        '''
+        Adds the given message to the notmuch index.
+
+        :param path: path to the message
+        :type  path: str
+        :param sync_maildir_flags: if `True` notmuch converts the
+                                   standard maildir flags to tags
+        :type  sync_maildir_flags: bool
+        :param new_mail_handler: callback for new messages
+        :type  new_mail_handler: a function that is called with a
+                                 :class:`notmuch.Message` object as
+                                 its only argument
+        :raises: :class:`notmuch.NotmuchError` if adding the message fails
+        :returns: a :class:`notmuch.Message` object
+        '''
+        # TODO: it would be nice to update notmuchs directory index here
+        message, status = self.open(rw=True).add_message(path, sync_maildir_flags=sync_maildir_flags)
+
+        if status != notmuch.STATUS.DUPLICATE_MESSAGE_ID:
+            logging.info('Found new mail in {}'.format(path))
+
+            for tag in get_notmuch_new_tags():
+                message.add_tag(tag)
+
+            if new_mail_handler:
+                new_mail_handler(message)
+
+        return message
+
+    def remove_message(self, path):
+        '''
+        Remove the given message from the notmuch index.
+
+        :param path: path to the message
+        :type  path: str
+        '''
+        self.open(rw=True).remove_message(path)
diff --git a/afew/FilterRegistry.py b/afew/FilterRegistry.py
new file mode 100644
index 0000000..ef77398
--- /dev/null
+++ b/afew/FilterRegistry.py
@@ -0,0 +1,77 @@
+# coding=utf-8
+from __future__ import print_function, absolute_import, unicode_literals
+
+#
+# Copyright (c) Justus Winter <4winter at informatik.uni-hamburg.de>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+import pkg_resources
+
+
+RAISEIT = object()
+
+
+class FilterRegistry(object):
+    """
+    The FilterRegistry is responsible for returning
+    filters by key.
+    Filters get registered via entry points.
+    To avoid any circular dependencies, the registry loads
+    the Filters lazily
+    """
+    def __init__(self, filters):
+        self._filteriterator = filters
+
+    @property
+    def filter(self):
+        if not hasattr(self, '_filter'):
+            self._filter = {}
+            for f in self._filteriterator:
+                self._filter[f.name] = f.load()
+        return self._filter
+
+    def get(self, key, default=RAISEIT):
+        if default == RAISEIT:
+            return self.filter[key]
+        else:
+            return self.filter.get(key, default)
+
+    def __getitem__(self, key):
+        return self.get(key)
+
+    def __setitem__(self, key, value):
+        self.filter[key] = value
+
+    def __delitem__(self, key):
+        del self.filter[key]
+
+    def keys(self):
+        return self.filter.keys()
+
+    def values(self):
+        return self.filter.values()
+
+    def items(self):
+        return self.filter.items()
+
+
+all_filters = FilterRegistry(pkg_resources.iter_entry_points('afew.filter'))
+
+def register_filter (klass):
+    '''Decorator function for registering a class as a filter.'''
+
+    all_filters[klass.__name__] = klass
+    return klass
+
diff --git a/afew/MailMover.py b/afew/MailMover.py
new file mode 100644
index 0000000..faa89c7
--- /dev/null
+++ b/afew/MailMover.py
@@ -0,0 +1,139 @@
+# coding=utf-8
+
+#
+# Copyright (c) dtk <dtk at gmx.de>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+
+import notmuch
+import logging
+import os, shutil
+from subprocess import check_call, CalledProcessError
+
+from .Database import Database
+from .utils import get_message_summary
+from datetime import date, datetime, timedelta
+import uuid
+
+
+class MailMover(Database):
+    '''
+    Move mail files matching a given notmuch query into a target maildir folder.
+    '''
+
+
+    def __init__(self, max_age=0, rename = False, dry_run=False):
+        super(MailMover, self).__init__()
+        self.db = notmuch.Database(self.db_path)
+        self.query = 'folder:{folder} AND {subquery}'
+        if max_age:
+            days = timedelta(int(max_age))
+            start = date.today() - days
+            now = datetime.now()
+            self.query += ' AND {start}..{now}'.format(start=start.strftime('%s'),
+                                                       now=now.strftime('%s'))
+        self.dry_run = dry_run
+        self.rename = rename
+
+    def get_new_name(self, fname, destination):
+        if self.rename:
+            return os.path.join(
+                            destination,
+                            # construct a new filename, composed of a made-up ID and the flags part
+                            # of the original filename.
+                            str(uuid.uuid1()) + ':' + os.path.basename(fname).split(':')[-1]
+                        )
+        else:
+            return destination
+
+    def move(self, maildir, rules):
+        '''
+        Move mails in folder maildir according to the given rules.
+        '''
+        # identify and move messages
+        logging.info("checking mails in '{}'".format(maildir))
+        to_delete_fnames = []
+        for query in rules.keys():
+            destination = '{}/{}/cur/'.format(self.db_path, rules[query])
+            main_query = self.query.format(folder=maildir, subquery=query)
+            logging.debug("query: {}".format(main_query))
+            messages = notmuch.Query(self.db, main_query).search_messages()
+            for message in messages:
+                # a single message (identified by Message-ID) can be in several
+                # places; only touch the one(s) that exists in this maildir 
+                all_message_fnames = message.get_filenames()
+                to_move_fnames = [name for name in all_message_fnames
+                                  if maildir in name]
+                if not to_move_fnames:
+                    continue
+                self.__log_move_action(message, maildir, rules[query],
+                                       self.dry_run)
+                for fname in to_move_fnames:
+                    if self.dry_run:
+                        continue
+                    try:
+                        shutil.copy2(fname, self.get_new_name(fname, destination))
+                        to_delete_fnames.append(fname)
+                    except shutil.Error as e:
+                        # this is ugly, but shutil does not provide more
+                        # finely individuated errors
+                        if str(e).endswith("already exists"):
+                            continue
+                        else:
+                            raise
+
+        # remove mail from source locations only after all copies are finished
+        for fname in set(to_delete_fnames):
+            os.remove(fname)
+
+        # update notmuch database
+        logging.info("updating database")
+        if not self.dry_run:
+            self.__update_db(maildir)
+        else:
+            logging.info("Would update database")
+
+
+    #
+    # private:
+    #
+
+    def __update_db(self, maildir):
+        '''
+        Update the database after mail files have been moved in the filesystem.
+        '''
+        try:
+            check_call(['notmuch', 'new'])
+        except CalledProcessError as err:
+            logging.error("Could not update notmuch database " \
+                          "after syncing maildir '{}': {}".format(maildir, err))
+            raise SystemExit
+
+
+    def __log_move_action(self, message, source, destination, dry_run):
+        '''
+        Report which mails have been identified for moving.
+        '''
+        if not dry_run:
+            level = logging.DEBUG
+            prefix = 'moving mail'
+        else:
+            level = logging.INFO
+            prefix = 'I would move mail'
+        logging.log(level, prefix)
+        logging.log(level, "    {}".format(get_message_summary(message).encode('utf8')))
+        logging.log(level, "from '{}' to '{}'".format(source, destination))
+        #logging.debug("rule: '{}' in [{}]".format(tag, message.get_tags()))
+
diff --git a/afew/NotmuchSettings.py b/afew/NotmuchSettings.py
new file mode 100644
index 0000000..13655e0
--- /dev/null
+++ b/afew/NotmuchSettings.py
@@ -0,0 +1,36 @@
+# coding=utf-8
+from __future__ import print_function, absolute_import, unicode_literals
+
+#
+# Copyright (c) Justus Winter <4winter at informatik.uni-hamburg.de>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+import os
+
+from .configparser import RawConfigParser
+
+notmuch_settings = RawConfigParser()
+
+def read_notmuch_settings(path = None):
+    if path == None:
+        path = os.environ.get('NOTMUCH_CONFIG', os.path.expanduser('~/.notmuch-config'))
+
+    notmuch_settings.readfp(open(path))
+
+def get_notmuch_new_tags():
+    return notmuch_settings.get_list('new', 'tags')
+
+def get_notmuch_new_query():
+    return '(%s)' % ' AND '.join('tag:%s' % tag for tag in get_notmuch_new_tags())
diff --git a/afew/Settings.py b/afew/Settings.py
new file mode 100644
index 0000000..fe2ed61
--- /dev/null
+++ b/afew/Settings.py
@@ -0,0 +1,109 @@
+# coding=utf-8
+from __future__ import print_function, absolute_import, unicode_literals
+
+#
+# Copyright (c) Justus Winter <4winter at informatik.uni-hamburg.de>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+import os
+import re
+import collections
+
+from .configparser import SafeConfigParser
+from afew.FilterRegistry import all_filters
+
+user_config_dir = os.path.join(os.environ.get('XDG_CONFIG_HOME',
+                                              os.path.expanduser('~/.config')),
+                               'afew')
+user_config_dir=os.path.expandvars(user_config_dir)
+
+settings = SafeConfigParser()
+# preserve the capitalization of the keys.
+settings.optionxform = str
+
+settings.readfp(open(os.path.join(os.path.dirname(__file__), 'defaults', 'afew.config')))
+settings.read(os.path.join(user_config_dir, 'config'))
+
+# All the values for keys listed here are interpreted as ;-delimited lists
+value_is_a_list = ['tags', 'tags_blacklist']
+mail_mover_section = 'MailMover'
+
+section_re = re.compile(r'^(?P<name>[a-z_][a-z0-9_]*)(\((?P<parent_class>[a-z_][a-z0-9_]*)\)|\.(?P<index>\d+))?$', re.I)
+def get_filter_chain(database):
+    filter_chain = []
... 3069 lines suppressed ...

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/python-modules/packages/afew.git



More information about the Python-modules-commits mailing list