1
from BaseHTTPServer import (
3
BaseHTTPRequestHandler,
5
1
from email.message import Message
6
2
from email.mime.multipart import MIMEMultipart
7
3
from email.mime.text import MIMEText
11
from signal import SIGKILL
13
4
from StringIO import StringIO
15
5
from unittest import TestCase
16
from urlparse import urlparse
17
from urlparse import parse_qs
19
7
from testtools import ExpectedException
21
from grackle.client import (
9
from grackle.client import GrackleClient
10
from grackle.error import (
23
12
UnparsableDateRange,
24
13
UnsupportedDisplayType,
16
from grackle.service import ForkedFakeService
17
from grackle.store import (
29
22
def make_message(message_id, body='body', headers=None, hidden=False):
30
23
if headers is None:
32
headers['Message-Id'] = message_id
34
'message_id': message_id,
36
'thread_id': message_id,
37
'date': headers.get('date', '2005-01-01'),
38
'subject': headers.get('subject', 'subject'),
39
'author': headers.get('author', 'author'),
42
'replies': headers.get('in-reply-to', None),
26
'Message-Id': message_id,
32
message_headers.update(headers.items())
34
message.set_payload(body)
35
for key, value in message_headers.items():
37
return make_json_message(message_id, message.as_string(), hidden)
48
40
def make_mime_message(message_id, body='body', headers=None, hidden=False,
49
41
attachment_type=None):
50
message = MIMEMultipart()
51
message.attach(MIMEText(body))
42
parts = MIMEMultipart()
43
parts.attach(MIMEText(body))
52
44
if attachment_type is not None:
53
45
attachment = Message()
54
46
attachment.set_payload('attactment data.')
55
47
attachment['Content-Type'] = attachment_type
56
48
attachment['Content-Disposition'] = 'attachment; filename="file.ext"'
57
message.attach(attachment)
58
return make_message(message_id, message.get_payload(), headers, hidden)
61
def threaded_messages(messages):
65
for message in messages:
66
if message.get('replies') is None:
67
threads[message['message_id']] = [message]
70
pending.append(message)
71
for message in pending:
72
threads[message['replies']].append(message)
73
return threads.values()
77
"""A memory-backed message store."""
79
def __init__(self, messages):
81
self.messages = messages
84
def is_multipart(message):
85
return isinstance(message['body'], list)
87
def get_messages(self, archive_id, query_string):
88
"""Return matching messages.
90
:param archive_id: The archive to retrieve from.
91
:param query_string: Contains 'parameters', which is a JSON-format
92
string describing parameters.
94
query = parse_qs(query_string)
95
parameters = simplejson.loads(query['parameters'][0])
96
order = parameters.get('order')
97
messages = self.messages[archive_id]
99
if order not in SUPPORTED_ORDERS:
100
raise UnsupportedOrder
101
elif order.startswith('thread_'):
102
threaded = threaded_messages(messages)
104
if order == 'thread_subject':
105
threaded.sort(key=lambda t: t[0]['subject'])
106
if order == 'thread_oldest':
107
threaded.sort(key=lambda t: min(m['date'] for m in t))
108
if order == 'thread_newest':
109
threaded.sort(key=lambda t: max(m['date'] for m in t))
110
for thread in threaded:
111
messages.extend(thread)
113
messages.sort(key=lambda m: m[order])
114
display_type = parameters.get('display_type', 'all')
115
if display_type not in SUPPORTED_DISPLAY_TYPES:
116
raise UnsupportedDisplayType
117
if 'date_range' in parameters:
119
start_date, end_date = parameters['date_range'].split('..')
120
if not start_date or not end_date:
121
raise UnparsableDateRange
123
raise UnparsableDateRange
125
for message in messages:
126
if (not parameters['include_hidden'] and message['hidden']):
128
if ('message_ids' in parameters
129
and message['message_id'] not in parameters['message_ids']):
131
if ('date_range' in parameters
132
and (message['date'] < start_date
133
or message['date'] > end_date)):
135
message = dict(message)
136
if 'headers' in parameters:
138
(k, v) for k, v in message['headers'].iteritems()
139
if k in parameters['headers'])
140
message['headers'] = headers
141
if display_type == 'headers-only':
143
elif display_type == 'text-only' and self.is_multipart(message):
145
part.get_payload() for part in message['body']
146
if part.get_content_type() == 'text/plain']
147
message['body'] = '\n\n'.join(text_parts)
148
elif display_type == 'all' and self.is_multipart(message):
149
parts = [str(part.get_payload()) for part in message['body']]
150
message['body'] = '\n\n'.join(parts)
151
max_body = parameters.get('max_body_length')
152
if max_body is not None and display_type != 'headers-only':
153
message['body'] = message['body'][:max_body]
154
new_messages.append(message)
155
messages = new_messages
156
limit = parameters.get('limit', 100)
157
memo = parameters.get('memo')
158
message_id_indices = dict(
159
(m['message_id'], idx) for idx, m in enumerate(messages))
163
start = message_id_indices[memo.encode('rot13')]
165
previous_memo = messages[start - 1]['message_id'].encode('rot13')
168
end = min(start + limit, len(messages))
169
if end < len(messages):
170
next_memo = messages[end]['message_id'].encode('rot13')
173
messages = messages[start:end]
176
'messages': messages,
177
'next_memo': next_memo,
178
'previous_memo': previous_memo
183
class ForkedFakeService:
184
"""A Grackle service fake, as a ContextManager."""
186
def __init__(self, port, messages=None, write_logs=False):
189
:param port: The tcp port to use.
190
:param messages: A dict of lists of dicts representing messages. The
191
outer dict represents the archive, the list represents the list of
192
messages for that archive.
193
:param write_logs: If true, log messages will be written to stdout.
200
self.messages = messages
201
self.read_end, self.write_end = os.pipe()
202
self.write_logs = write_logs
205
def from_client(client, messages=None):
206
"""Instantiate a ForkedFakeService from the client.
208
:param port: The client to provide service for.
209
:param messages: A dict of lists of dicts representing messages. The
210
outer dict represents the archive, the list represents the list of
211
messages for that archive.
213
return ForkedFakeService(client.port, messages)
216
"""Tell the parent process that the server is ready for writes."""
217
os.write(self.write_end, 'asdf')
222
Fork and start a server in the child. Return when the server is ready
228
os.read(self.read_end, 1)
231
def start_server(self):
232
"""Start the HTTP server."""
233
service = HTTPServer(('', self.port), FakeGrackleRequestHandler)
234
service.store = GrackleStore(self.messages)
235
for archive_id, messages in service.store.messages.iteritems():
236
for message in messages:
237
message.setdefault('headers', {})
241
stream=sys.stderr, level=logging.INFO)
242
service.serve_forever()
244
def __exit__(self, exc_type, exc_val, traceback):
245
os.kill(self.pid, SIGKILL)
248
SUPPORTED_DISPLAY_TYPES = set(['all', 'text-only', 'headers-only'])
251
SUPPORTED_ORDERS = set(
252
['date', 'author', 'subject', 'thread_newest', 'thread_oldest',
256
class FakeGrackleRequestHandler(BaseHTTPRequestHandler):
257
"""A request handler that forwards to server.store."""
259
def __init__(self, *args, **kwargs):
260
"""Constructor. Sets up logging."""
261
self.logger = logging.getLogger('http')
262
BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
265
"""Create a message on POST."""
266
message = self.rfile.read(int(self.headers['content-length']))
267
if message == 'This is a message':
268
self.send_response(httplib.CREATED)
272
self.send_error(httplib.BAD_REQUEST)
275
"""Retrieve a list of messages on GET."""
276
scheme, netloc, path, params, query_string, fragments = (
278
parts = path.split('/')
279
if parts[1] == 'archive':
281
response = self.server.store.get_messages(
282
parts[2], query_string)
283
self.send_response(httplib.OK)
285
self.wfile.write(simplejson.dumps(response))
286
except Exception, error:
288
httplib.BAD_REQUEST, error.__doc__)
291
def log_message(self, format, *args):
292
"""Override log_message to use standard Python logging."""
293
message = "%s - - [%s] %s\n" % (
294
self.address_string(), self.log_date_time_string(), format % args)
295
self.logger.info(message)
49
parts.attach(attachment)
50
return make_message(message_id, parts.as_string(), headers, hidden)
53
class TestPutArchive(TestCase):
55
def test_put_archive(self):
56
client = GrackleClient('localhost', 8410)
58
with ForkedFakeService.from_client(client, message_archives):
59
client.put_archive('arch1')
60
response = client.get_messages('arch1')
61
self.assertEqual(0, len(response['messages']))
63
def test_put_archive_existing_archive(self):
64
client = GrackleClient('localhost', 8411)
65
message_archives = {'arch1': []}
66
with ForkedFakeService.from_client(client, message_archives):
67
with ExpectedException(ArchiveIdExists, ''):
68
client.put_archive('arch1')
298
71
class TestPutMessage(TestCase):
300
73
def test_put_message(self):
301
client = GrackleClient('localhost', 8436)
302
with ForkedFakeService.from_client(client):
303
client.put_message('arch1', 'asdf', StringIO('This is a message'))
74
client = GrackleClient('localhost', 8420)
75
message_archives = {'arch1': []}
76
with ForkedFakeService.from_client(client, message_archives):
77
client.put_message('arch1', 'id1', StringIO('This is a message'))
78
response = client.get_messages('arch1')
79
self.assertEqual(1, len(response['messages']))
80
message = response['messages'][0]
81
self.assertEqual('id1', message['message_id'])
83
def test_put_message_without_archive(self):
84
client = GrackleClient('localhost', 8421)
85
message_archives = {'arch1': []}
86
with ForkedFakeService.from_client(client, message_archives):
304
87
with ExpectedException(Exception, 'wtf'):
305
client.put_message('arch1', 'asdf',
306
StringIO('This is not a message'))
88
client.put_message('no-archive', 'id1', StringIO('message'))
309
91
class TestGetMessages(TestCase):