Skip to content

Commit

Permalink
Cleanup code, make code snap-package aware. introduce testtools and f…
Browse files Browse the repository at this point in the history
…ixtures dependencies.
  • Loading branch information
thomir committed May 15, 2016
1 parent 0c60046 commit fe08b29
Show file tree
Hide file tree
Showing 12 changed files with 215 additions and 142 deletions.
14 changes: 7 additions & 7 deletions gmailfilter/_command.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@

import logging
import os
import sys
from argparse import ArgumentParser

from gmailfilter._config import ServerInfo
from gmailfilter._connection import (
IMAPConnection,
ServerInfo,
default_credentials_file_location,
)
from gmailfilter import _rules
Expand All @@ -17,9 +16,6 @@ def run():
args = configure_argument_parser()
log_level = logging.DEBUG if args.verbose else logging.INFO
logging.basicConfig(level=log_level, stream=sys.stdout)
if args.dev:
# run_old_filter()
print("The --dev option is deprecated. The New hotness is everywhere now.")
run_new_filter()


Expand Down Expand Up @@ -65,6 +61,10 @@ def configure_argument_parser():
prog="gmailfilter",
description="Filter IMAP emails the easy way!"
)
parser.add_argument('-v', '--verbose', action='store_true', help="Be more verbose")
parser.add_argument('--dev', action='store_true', help="Run new, development code.")
parser.add_argument(
'-v',
'--verbose',
action='store_true',
help="Be more verbose"
)
return parser.parse_args()
119 changes: 119 additions & 0 deletions gmailfilter/_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import configparser
import logging
import os
import os.path
import textwrap
import stat


class ServerInfo(object):
"""A class that encapsulates information about how to connect to a server.
Knows how to read from a config file on disk, create a template config
file.
"""

_default_options = {
'port': '993',
'use_ssl': 'True',
}

def __init__(self, host, username, password, port, use_ssl):
if not host:
raise KeyError('host')
if not username:
raise KeyError('username')
if not password:
raise KeyError('password')
self.host = host
self.username = username
self.password = password
self.port = port
self.use_ssl = use_ssl

@classmethod
def read_config_file(cls, path=None):
"""Read credentials from a config file, return a ServerInfo instance.
This function will log a warning if the credentials file exists and is
group or world readable (your imap credentials should be private!),
but will return a valid ServerInfo instance.
If the path to the credentals file cannot be found, it will raise an
IOError.
If the path exists, but cannot be parsed, a RuntimeError will be
raised with the parse failure reason.
If required keys are missing, KeyError is raised.
"""
path = path or default_credentials_file_location()
if not os.path.exists(path):
raise IOError("Could not read path {}".format(path))
if os.stat(path).st_mode & (stat.S_IRWXG | stat.S_IRWXO):
logging.warning(
"The credentials file at '{0}' is readable by other users on "
"this system. To eliminate this security risk, run "
"'chmod go-rwx {0}'.".format(path)
)
parser = configparser.ConfigParser(defaults=cls._default_options)
try:
parser.read(path)
except configparser.ParsingError as e:
raise RuntimeError(
"Could not parse credentials file '{}'. Error was:\n{}".format(
path,
str(e)
)
)
return cls(
host=parser['server']['host'],
username=parser['server']['username'],
password=parser['server']['password'],
port=parser['server']['port'],
use_ssl=parser['server']['use_ssl']
)

@classmethod
def write_template_config_file(cls, path=None):
"""Write a template config file to disk."""
path = path or default_credentials_file_location()
directory = os.path.dirname(path)
if not os.path.exists(directory):
os.makedirs(directory)
with open(path, 'w') as template_file:
template_file.write(textwrap.dedent('''
# Credentials config file.
# Comments start with a '#'. See comments below for
# detailed information on each option.
[server]
# REQUIRED: The domain name or ip address of the IMAP
# server to connect to:
host =
# REQUIRED: The username to log in to the IMAP server with
username =
# REQUIRED: The password to log in to the IMAP server with
password =
# OPTIONAL: Whether or not to connect with SSL. Default is
# to use SSL. Uncomment this and change it to False to
# connect without SSL.
#use_ssl = True
# OPTIONAL: The port to connect to on the host.
#port = 993
'''))
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)


def default_credentials_file_location():
if 'SNAP_USER_DATA' in os.environ:
return os.path.join(os.environ['SNAP_USER_DATA'], 'credentials.ini')
return os.path.expanduser('~/.config/gmailfilter/credentials.ini')
126 changes: 10 additions & 116 deletions gmailfilter/_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,18 @@ def get_message_part(self, part_name):
if retrieve_key not in self._data:
with self._connection.use_uid():
msg_uid = self._data[b'UID']
# for some reason, sometimes a fetch call returns an empty dict.
# until I find out why, I'll simply retry this:
# for some reason, sometimes a fetch call returns an empty
# dict. until I find out why, I'll simply retry this:
data = {}
for i in range(3):
data = self._connection._client.fetch(msg_uid, part_name)
if data:
self._data.update(data[msg_uid])
break
assert msg_uid in data, ("Server gave us back some other data: %d %r" % (msg_uid, data))
assert msg_uid in data, (
"Server gave us back some other data: %d %r"
% (msg_uid, data)
)
return self._data[retrieve_key]


Expand Down Expand Up @@ -117,7 +120,9 @@ def get_messages(self):
# config file?:
i = 0
with self.use_sequence():
for chunk in sequence_chunk(total_messages, optimal_chunk_size(1000)):
for chunk in sequence_chunk(
total_messages,
optimal_chunk_size(1000)):
logging.info("Fetching: " + chunk)
data = self._client.fetch(
chunk,
Expand Down Expand Up @@ -186,6 +191,7 @@ def __getattribute__(self, name):
# Wrap these functions so they always use the uid, not the sequence
# id.
function = getattr(self._wrapped, name)

def wrapper(client, fn, *args, **kwargs):
old = client.use_uid
client.use_uid = True
Expand All @@ -196,115 +202,3 @@ def wrapper(client, fn, *args, **kwargs):
return functools.partial(wrapper, self._wrapped, function)

raise AttributeError(name)


class ServerInfo(object):
"""A class that encapsulates information about how to connect to a server.
Knows how to read from a config file on disk, create a template config
file.
"""

_default_options = {
'port': '993',
'use_ssl': 'True',
}

def __init__(self, host, username, password, port, use_ssl):
if not host:
raise KeyError('host')
if not username:
raise KeyError('username')
if not password:
raise KeyError('password')
self.host = host
self.username = username
self.password = password
self.port = port
self.use_ssl = use_ssl

@classmethod
def read_config_file(cls, path=None):
"""Read credentials from a config file, return a ServerInfo instance.
This function will log a warning if the credentials file exists and is
group or world readable (your imap credentials should be private!),
but will return a valid ServerInfo instance.
If the path to the credentals file cannot be found, it will raise an
IOError.
If the path exists, but cannot be parsed, a RuntimeError will be
raised with the parse failure reason.
If required keys are missing, KeyError is raised.
"""
path = path or default_credentials_file_location()
if not os.path.exists(path):
raise IOError("Could not read path {}".format(path))
if os.stat(path).st_mode & (stat.S_IRWXG | stat.S_IRWXO):
logging.warning(
"The credentials file at '{0}' is readable by other users on "
"this system. To eliminate this security risk, run "
"'chmod go-rwx {0}'.".format(path)
)
parser = configparser.ConfigParser(defaults=cls._default_options)
try:
parser.read(path)
except configparser.ParsingError as e:
raise RuntimeError(
"Could not parse credentials file '{}'. Error was:\n{}".format(
path,
str(e)
)
)
return cls(
host = parser['server']['host'],
username = parser['server']['username'],
password = parser['server']['password'],
port = parser['server']['port'],
use_ssl = parser['server']['use_ssl']
)

@classmethod
def write_template_config_file(cls, path=None):
"""Write a template config file to disk."""
path = path or default_credentials_file_location()
directory = os.path.dirname(path)
if not os.path.exists(directory):
os.makedirs(directory)
with open(path, 'w') as template_file:
template_file.write(textwrap.dedent('''
# Credentials config file.
# Comments start with a '#'. See comments below for
# detailed information on each option.
[server]
# REQUIRED: The domain name or ip address of the IMAP
# server to connect to:
host =
# REQUIRED: The username to log in to the IMAP server with
username =
# REQUIRED: The password to log in to the IMAP server with
password =
# OPTIONAL: Whether or not to connect with SSL. Default is
# to use SSL. Uncomment this and change it to False to
# connect without SSL.
#use_ssl = True
# OPTIONAL: The port to connect to on the host.
#port = 993
''')
)
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)


def default_credentials_file_location():
return os.path.expanduser('~/.config/gmailfilter/credentials.ini')
6 changes: 5 additions & 1 deletion gmailfilter/_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ def write_default_rules_file(path=None):
'''
))


def default_rules_path():
if 'SNAP_USER_DATA' in os.environ:
return os.path.join(os.environ['SNAP_USER_DATA'], 'rules.py')
return os.path.expanduser('~/.config/gmailfilter/rules.py')


Expand Down Expand Up @@ -93,7 +96,8 @@ def check_rules(rules):
)
test = rule[0]
if not callable(getattr(test, 'match', None)):
raise RuleLoadError('Test for rule {} does not have a '
raise RuleLoadError(
'Test for rule {} does not have a '
'callable "match" method.'.format(rule_repr)
)

Expand Down
9 changes: 5 additions & 4 deletions gmailfilter/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ def __init__(self, target_folder):
def process(self, conn, message):
try:
conn.copy(message.uid(), self._target_folder)
except imapclient.IMAPClient.Error as e:
except imapclient.IMAPClient.Error:
status = conn.create_folder(self._target_folder)
assert status.lower() == b"success", "Unable to create folder %s" % self._target_folder
assert status.lower() == b"success", \
"Unable to create folder %s" % self._target_folder
conn.copy(message.uid(), self._target_folder)


# TODO: Maybe provide logging facilities in parent 'Action' class?
conn.delete_messages(message.uid())
logging.info("Moving message %r to %s" % (message, self._target_folder))
logging.info(
"Moving message %r to %s" % (message, self._target_folder))


class DeleteMessage(Action):
Expand Down
1 change: 1 addition & 0 deletions gmailfilter/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
'MatchesHeader',
]


class Test(object):

"""This class represents a single test on a message.
Expand Down
29 changes: 29 additions & 0 deletions gmailfilter/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import os.path

from testtools import TestCase
import fixtures

from gmailfilter._config import default_credentials_file_location


class ConfigTests(TestCase):

def test_default_path_in_normal_mode(self):
fake_home = '/some/fake/home'
self.useFixture(fixtures.EnvironmentVariable('HOME', fake_home))
path = default_credentials_file_location()

expected = os.path.join(
fake_home,
'.config/gmailfilter/credentials.ini'
)
self.assertEqual(expected, path)

def test_defailt_path_in_snap_mode(self):
fake_home = '/snap/foo'
self.useFixture(
fixtures.EnvironmentVariable('SNAP_USER_DATA', fake_home))
path = default_credentials_file_location()

expected = os.path.join(fake_home, 'credentials.ini')
self.assertEqual(expected, path)
Loading

0 comments on commit fe08b29

Please sign in to comment.