Merge lp:~flo-fuchs/mailman/restclient into lp:mailman

Proposed by Florian Fuchs
Status: Merged
Merge reported by: Barry Warsaw
Merged at revision: not available
Proposed branch: lp:~flo-fuchs/mailman/restclient
Merge into: lp:mailman
Diff against target: 396 lines (+385/-0)
2 files modified
src/mailman/rest/docs/restclient.txt (+124/-0)
src/mailmanclient/rest.py (+261/-0)
To merge this branch: bzr merge lp:~flo-fuchs/mailman/restclient
Reviewer Review Type Date Requested Status
Barry Warsaw Approve
Florian Fuchs Needs Resubmitting
Mailman Coders Pending
Review via email: mp+28522@code.launchpad.net

Description of the change

I added a rest client in src/mailmanclient as well as a doctest in src/mailman/rest/docs/restclient.txt.

To post a comment you must log in.
Revision history for this message
Barry Warsaw (barry) wrote :
Download full text (19.5 KiB)

Thanks for getting this client library started, Florian! Here are some
thoughts on your merge proposal.

As we discussed on IRC, I now think the best place for this code is in
src/mailmanclient. Don't worry about that though; I'll fix that up when I
merge to trunk. I know for now it's difficult getting the test suite to run
with that layout, so I'll take that on. I don't think it will be too
difficult, but there will also be some other useful helpers to share.

Omitting the non-controversial stuff.

=== added file 'src/mailman/rest/docs/restclient.txt'
--- src/mailman/rest/docs/restclient.txt 1970-01-01 00:00:00 +0000
+++ src/mailman/rest/docs/restclient.txt 2010-06-25 16:50:42 +0000
> @@ -0,0 +1,89 @@
> +===================
> +Mailman REST Client
> +===================
> +
> +Domains
> +=======
> +
> + # The test framework starts out with an example domain, so let's delete
> + # that first.
> + >>> from mailman.interfaces.domain import IDomainManager
> + >>> from zope.component import getUtility
> + >>> domain_manager = getUtility(IDomainManager)
> +
> + >>> domain_manager.remove('example.com')
> + <Domain example.com...>
> + >>> transaction.commit()

The test infrastructure currently sets up this sample domain. I wonder
whether the REST client tests will more often want to start from a clean
slate, or want this example domain. I think perhaps the former, so I should
probably add a layer that is just like the ConfigLayer, but has an empty
testSetUp(). (You don't have to worry about that for now. :)

> +In order to add new lists first a new domain has to be added.
> +
> + >>> from mailmanclient.rest import MailmanRESTClient, MailmanRESTClientError
> + >>> c = MailmanRESTClient('localhost:8001')
> + >>> c.create_domain('example.com')
> + True

What do you think about having proxy objects for Domains, Lists, etc.?
Launchpadlib works like this and it provides more of a natural, object
oriented API to clients, rather than an XMLRPC style seen here.

So for example, .create_domain() would return a Domain surrogate, and you'd
call things like .get_lists() and .create_list() on that object.

How much harder do you think it would be to do something like that, and do you
think it would be worth it?

> +
> +
> +Mailing lists
> +=============
> +
> +You can get a lists of all lists by calling get_lists(). If no lists have been created yet, MailmanRESTClientError is raised.

Please wrap narrative to 78 characters.

> +
> + >>> lists = c.get_lists()
> + Traceback (most recent call last):
> + ...
> + MailmanRESTClientError: No mailing lists found

I think it might be better to return an empty list instead of raising an
exception here. Think about application code like so:

    # Display all the current lists.
    try:
        lists = c.get_lists()
    except MailmanRESTClientError:
        # Are we sure we got "No mailing lists found" or did some other
        # generic client error occur?
        lists = []
    for mailing_list in lists:
        # ...

The thing is, having no mailing lists isn't an error condition, so this should
probably return an empty (Python) list rather than raise an exception.
...

lp:~flo-fuchs/mailman/restclient updated
6916. By Florian Fuchs

added _Domain and _List classes, fixed code style issues

Revision history for this message
Florian Fuchs (flo-fuchs) wrote :

First of all: Thanks a lot for that extensive review - I really, really appreciate getting this kind of detailed comment!

Exceptions
==========
MailmanRESTClientError is only used for email- and domain-validation. Apart from that the original Exceptions don't get catched any more.

If there are no mailing lists yet, an empty list is returned instead of an Exception.

Proxy Objects
=============
> What do you think about having proxy objects for Domains, Lists, etc.?
> Launchpadlib works like this and it provides more of a natural, object
> oriented API to clients, rather than an XMLRPC style seen here.

Great idea! I've added two sub-classes two return as List and Domain objects like:
domain = client.get_domain('example.com')
list = domain.create_list('test')
and so on...
No user object yet: What do you think: Are we talking about a User with n email addresses and n memberships here or more like User = Membership?

Generally I'd say "get_singularterm" and "create_singularterm" (get_list(), create_domain() etc.) should return an object with helpful methods and "get_pluralterm"/"create_pluralterm" (get_lists, get_members) should return lists oder dicts...

Style issues
============
I've fixed all the issues according to the style guide and pep8. At least I hope so... Lesson learned... ;-)

Func name issue
===============

> I'm not sure 'reading' a list is the right phrase here. "Reading" a list
> implies to me reading its archive. Maybe .get_list() here?

I was thinking in CRUD terms where I understand "reading" more like getting a db record. But I agree, it's a little strange here. So I changed it to get_list().

List order
==========
> This is why I added the dump_json() helper in
> src/mailman/tests/test_documentation.py. That helper isn't entirely
> appropriate for your tests because you don't explicitly pass in a url; that's
> basically embedded in .get_lists and the client. But there's enough
> commonality that dump_json() should be refactored into something that can be
> shared.

I'm not sure if a func name like dump_json is a little confusing in that context since the client doesn't return any json. Maybe we could rename the function into dump_rest_output() (or similar) and make url, method optional? So if url is set, the rest server is called; if not, only data is sorted. `data` would then either server as a parm for POST/PUT content or as a dict to sort...

For now I've added the sort logic to the test (only a couple of lines).

So, thanks again for reviewing. On to the next round...? :-)

Florian

lp:~flo-fuchs/mailman/restclient updated
6917. By Florian Fuchs

added some line breaks in the restclient doctest file; changed confusing function names (http helper functions) in rest client

6918. By Florian Fuchs

fixed email validation in restclient to work with email addresses containing subdomains

Revision history for this message
Barry Warsaw (barry) wrote :
Download full text (18.8 KiB)

Thanks for all the great updates. Things look pretty good, and I have only a
few minor issues to comment on. I think we're almost ready to merge it!
Great work.

On the dump_json() issue, i just thought of something: since you're only
displaying dictionaries, perhaps pprint will do the trick. In Python 2.6,
pprint.pprint() sorts the dictionary elements I believe.

    >>> import os
    >>> from pprint import pprint
    >>> pprint(dict(os.environ))
    {'COLUMNS': '79',
     ...
     'DISPLAY': ':0.0',
     'EMACS': 't',
     ...

You asked:

>No user object yet: What do you think: Are we talking about a User with n
>email addresses and n memberships here or more like User = Membership?

I think we should stick fairly close to the internal model here. So 'Users'
represent people, 'Addresses' represent their email addresses, which are
usually associated with a user, and 'Member' joins an address to a mailing
list with a given role. Only indirectly can you get at the user for a member
(i.e. through its address).

I agree about singular/plural terms.

-B

=== added file 'src/mailman/rest/docs/restclient.txt'
--- src/mailman/rest/docs/restclient.txt 1970-01-01 00:00:00 +0000
+++ src/mailman/rest/docs/restclient.txt 2010-07-15 14:02:55 +0000
> @@ -0,0 +1,129 @@
> +===================
> +Mailman REST Client
> +===================
> +
> + # The test framework starts out with an example domain, so let's delete
> + # that first.
> + >>> from mailman.interfaces.domain import IDomainManager
> + >>> from zope.component import getUtility
> + >>> domain_manager = getUtility(IDomainManager)
> +
> + >>> domain_manager.remove('example.com')
> + <Domain example.com...>
> + >>> transaction.commit()
> +
> +First let's get an instance of MailmanRESTClient.
> +
> + >>> from mailmanclient.rest import MailmanRESTClient, MailmanRESTClientError
> + >>> client = MailmanRESTClient('localhost:8001')
> +
> +So far there are no lists.
> +
> + >>> lists = client.get_lists()
> + >>> print lists
> + []

I think you can just do

    >>> client.get_lists()
    []

> +
> +
> +Domains
> +=======
> +
> +In order to add new lists first a new domain has to be added.
> +
> + >>> new_domain = client.create_domain('example.com')
> + >>> new_domaininfo = new_domain.get_domaininfo()
> + >>> for key in sorted(new_domaininfo):
> + ... print '{0}: {1}'.format(key, new_domaininfo[key])
> + base_url: http://example.com
> + ...
> +
> +Later the domain object can be instanciated using get_domain()
> +
> + >>> my_domain = client.get_domain('example.com')
> +
> +
> +Mailing lists
> +=============
> +
> +Now let's add some mailing lists.
> +
> + >>> new_list = my_domain.create_list('test-one')
> +
> +Lets add another list and get some information on the list.

s/Lets/let's/

> +
> + >>> another_list = my_domain.create_list('test-two')
> + >>> another_listinfo = another_list.get_listinfo()
> + >>> for key in sorted(another_listinfo):
> + ... print '{0}: {1}'.format(key, another_listinfo[key])
> + fqdn_listname: <email address hidden>
> + ...
> +
> +Later the new list can be instanciated using get_list():

s/i...

review: Needs Fixing
lp:~flo-fuchs/mailman/restclient updated
6919. By Florian Fuchs

changes to the restclient lib:
  * use httplib2 instead of httplib
  * some style, typo and best-practice fixes
  * use pprint in test file
  * refactored http helper methods (only one used now instead of one for each HTTP method)
  * removed host and email address validation (not necessary)
  * use operator.itemgetter instead of lambdas to sort dicts

Revision history for this message
Florian Fuchs (flo-fuchs) wrote :

I did some fixes and improvements like suggested in the last review...

> > + entry 0:
> > + ...
> > + self_link: http://localhost:8001/3.0/lists/test-
> <email address hidden><email address hidden>
> > + entry 1:
> > + ...
> > + self_link: http://localhost:8001/3.0/lists/test-
> <email address hidden><email address hidden>
> > + entry 2:
> > + ...
> > + self_link: http://localhost:8001/3.0/lists/test-
> <email address hidden><email address hidden>
>
> The client is returning json here, right?

Nope, the client never returns json. Either HTTP status codes or lists/dicts are returned.

> Should we be using httplib2 and urllib2 here? See the implementation of
> dump_json().

Done.

> > + def _delete_request(self, path):
> > + """Send an HTTP DELETE request.
> > +
> > + :param path: the URL to send the DELETE request to
> > + :type path: string
> > + :return: request status code
> > + :rtype: string
> > + """
> > + try:
> > + self.c.request('DELETE', path, None, self.headers)
> > + r = self.c.getresponse()
> > + return r.status
> > + finally:
> > + self.c.close()
>
> I wonder if this duplication can be refactored?

There's only one http request method now.

> > + def _validate_email_host(self, email_host):
> > + """Validates a domain name.
> > +
> > + :param email_host: the domain str to validate
> > + :type email_host: string
> > + """
> > + pat = re.compile('^[-a-z0-9\.]+\.[-a-z]{2,4}$', re.IGNORECASE)
> > + if not pat.match(email_host):
> > + raise MailmanRESTClientError('%s is not a valid domain name' %
> email_host)
>
> Won't the Mailman core refuse to create a domain if it's not valid? It might
> still be worth doing client-side validation, but I would expect that more in
> some webui JavaScript. What's the advantage of doing this extra check (which
> might be different than what happens in the core)?

I didn't know if the core does email validation. Also, the django app does some validation. So I removed it.

> I wonder if this method is necessary. In general, attributes are preferred
> over accessors, and you've already got a public one right here! So clients
> can do:
>
> >>> my_domain = client.get_domain('example.com')
> >>> my_domain.domain_info
> ...
>
> directly. In fact, for polymorphism, maybe the attribute should just be
> called 'info'?

Done.

review: Needs Resubmitting
Revision history for this message
Barry Warsaw (barry) wrote :

I finally managed to figure out how to deploy this. See http://launchpad.net/mailman.client and the trunk branch of that. Thanks so much for the contribution!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/mailman/rest/docs/restclient.txt'
2--- src/mailman/rest/docs/restclient.txt 1970-01-01 00:00:00 +0000
3+++ src/mailman/rest/docs/restclient.txt 2010-07-20 21:14:46 +0000
4@@ -0,0 +1,124 @@
5+===================
6+Mailman REST Client
7+===================
8+
9+ >>> from pprint import pprint
10+
11+ # The test framework starts out with an example domain, so let's delete
12+ # that first.
13+ >>> from mailman.interfaces.domain import IDomainManager
14+ >>> from zope.component import getUtility
15+ >>> domain_manager = getUtility(IDomainManager)
16+
17+ >>> domain_manager.remove('example.com')
18+ <Domain example.com...>
19+ >>> transaction.commit()
20+
21+First let's get an instance of MailmanRESTClient.
22+
23+ >>> from mailmanclient.rest import MailmanRESTClient, MailmanRESTClientError
24+ >>> client = MailmanRESTClient('localhost:8001')
25+
26+So far there are no lists.
27+
28+ >>> client.get_lists()
29+ []
30+
31+
32+Domains
33+=======
34+
35+In order to add new lists first a new domain has to be added.
36+
37+ >>> new_domain = client.create_domain('example.com')
38+ >>> pprint(new_domain.info)
39+ {u'base_url': u'http://example.com',
40+ u'contact_address': u'postmaster@example.com',
41+ u'description': None,
42+ u'email_host': u'example.com',
43+ u'http_etag': u'"6b1ccf042e8f76138a0bd37e8509f364da92a5c5"',
44+ u'self_link': u'http://localhost:8001/3.0/domains/example.com',
45+ u'url_host': u'example.com'}
46+
47+Later the domain object can be instantiated using get_domain()
48+
49+ >>> my_domain = client.get_domain('example.com')
50+
51+
52+Mailing lists
53+=============
54+
55+Now let's add s mailing list called 'test-one'.
56+
57+ >>> new_list = my_domain.create_list('test-one')
58+
59+Let's add another list and get some information on the list.
60+
61+ >>> another_list = my_domain.create_list('test-two')
62+ >>> pprint(another_list.info)
63+ {u'fqdn_listname': u'test-two@example.com',
64+ u'host_name': u'example.com',
65+ u'http_etag': u'"a05542c9faa07cbe2b8fdf8a1655a2361ab365f2"',
66+ u'list_name': u'test-two',
67+ u'real_name': u'Test-two',
68+ u'self_link': u'http://localhost:8001/3.0/lists/test-two@example.com'}
69+
70+Later the new list can be instantiated using get_list():
71+
72+ >>> some_list = client.get_list('test-one@example.com')
73+
74+The lists have been added and get_lists() returns a list of dicts, sorted
75+by fqdn_listname.
76+
77+ >>> pprint(client.get_lists())
78+ [{u'fqdn_listname': u'test-one@example.com',
79+ u'host_name': u'example.com',
80+ u'http_etag': u'"5e99519ef1b823a52254b77e89bec54fbd17bef0"',
81+ u'list_name': u'test-one',
82+ u'real_name': u'Test-one',
83+ u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com'},
84+ {u'fqdn_listname': u'test-two@example.com',
85+ u'host_name': u'example.com',
86+ u'http_etag': u'"a05542c9faa07cbe2b8fdf8a1655a2361ab365f2"',
87+ u'list_name': u'test-two',
88+ u'real_name': u'Test-two',
89+ u'self_link': u'http://localhost:8001/3.0/lists/test-two@example.com'}]
90+
91+
92+Membership
93+==========
94+
95+Since we now have a list we should add some members to it (.subscribe()
96+returns an HTTP status code, ideally 201)
97+
98+ >>> new_list.subscribe('jack@example.com', 'Jack')
99+ 201
100+ >>> new_list.subscribe('meg@example.com', 'Meg')
101+ 201
102+ >>> another_list.subscribe('jack@example.com', 'Jack')
103+ 201
104+
105+We can get a list of all members:
106+
107+ >>> pprint(client.get_members())
108+ [{u'http_etag': u'"320f9e380322cafbbf531c11eab1ec9d38b3bb99"',
109+ u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com/member/jack@example.com'},
110+ {u'http_etag': u'"cd75b7e93216a022573534d948511edfbfea06cd"',
111+ u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com/member/meg@example.com'},
112+ {u'http_etag': u'"13399f5ebbab8c474926a7ad0ccfda28d717e398"',
113+ u'self_link': u'http://localhost:8001/3.0/lists/test-two@example.com/member/jack@example.com'}]
114+
115+Or just the members of a specific list:
116+
117+ >>> pprint(new_list.get_members())
118+ [{u'http_etag': u'"320f9e380322cafbbf531c11eab1ec9d38b3bb99"',
119+ u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com/member/jack@example.com'},
120+ {u'http_etag': u'"cd75b7e93216a022573534d948511edfbfea06cd"',
121+ u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com/member/meg@example.com'}]
122+
123+After a while Meg decides to unsubscribe from the mailing list (like
124+.subscribe() .unsubscribe() returns an HTTP status code, ideally 200).
125+
126+ >>> new_list.unsubscribe('meg@example.com')
127+ 200
128+
129
130=== added directory 'src/mailmanclient'
131=== added file 'src/mailmanclient/__init__.py'
132=== added file 'src/mailmanclient/rest.py'
133--- src/mailmanclient/rest.py 1970-01-01 00:00:00 +0000
134+++ src/mailmanclient/rest.py 2010-07-20 21:14:46 +0000
135@@ -0,0 +1,261 @@
136+# Copyright (C) 2010 by the Free Software Foundation, Inc.
137+#
138+# This file is part of GNU Mailman.
139+#
140+# GNU Mailman is free software: you can redistribute it and/or modify it under
141+# the terms of the GNU General Public License as published by the Free
142+# Software Foundation, either version 3 of the License, or (at your option)
143+# any later version.
144+#
145+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
146+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
147+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
148+# more details.
149+#
150+# You should have received a copy of the GNU General Public License along with
151+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
152+
153+"""A client library for the Mailman REST API."""
154+
155+
156+from __future__ import absolute_import, unicode_literals
157+
158+__metaclass__ = type
159+__all__ = [
160+ 'MailmanRESTClient',
161+ 'MailmanRESTClientError',
162+ ]
163+
164+
165+import re
166+import json
167+
168+from httplib2 import Http
169+from operator import itemgetter
170+from urllib import urlencode
171+from urllib2 import HTTPError
172+
173+
174+class MailmanRESTClientError(Exception):
175+ """An exception thrown by the Mailman REST API client."""
176+
177+
178+class MailmanRESTClient():
179+ """A wrapper for the Mailman REST API."""
180+
181+ def __init__(self, host):
182+ """Check and modify the host name.
183+
184+ :param host: the host name of the REST API
185+ :type host: string
186+ :return: a MailmanRESTClient object
187+ :rtype: objectFirst line should
188+ """
189+ self.host = host
190+ # If there is a trailing slash remove it
191+ if self.host[-1] == '/':
192+ self.host = self.host[:-1]
193+ # If there is no protocol, fall back to http://
194+ if self.host[0:4] != 'http':
195+ self.host = 'http://' + self.host
196+
197+ def __repr__(self):
198+ return '<MailmanRESTClient: %s>' % self.host
199+
200+ def _http_request(self, path, data=None, method=None):
201+ """Send an HTTP request.
202+
203+ :param path: the path to send the request to
204+ :type path: string
205+ :param data: POST oder PUT data to send
206+ :type data: dict
207+ :param method: the HTTP method; defaults to GET or POST (if
208+ data is not None)
209+ :type method: string
210+ :return: the request content or a status code, depending on the
211+ method and if the request was successful
212+ :rtype: int, list or dict
213+ """
214+ url = self.host + path
215+ # Include general header information
216+ headers = {
217+ 'User-Agent': 'MailmanRESTClient',
218+ 'Accept': 'text/plain',
219+ }
220+ if data is not None:
221+ data = urlencode(data)
222+ if method is None:
223+ if data is None:
224+ method = 'GET'
225+ else:
226+ method = 'POST'
227+ method = method.upper()
228+ if method == 'POST':
229+ headers['Content-type'] = "application/x-www-form-urlencoded"
230+ response, content = Http().request(url, method, data, headers)
231+ if method == 'GET':
232+ if response.status // 100 != 2:
233+ return response.status
234+ else:
235+ return json.loads(content)
236+ else:
237+ return response.status
238+
239+ def create_domain(self, email_host):
240+ """Create a domain and return a domain object.
241+
242+ :param email_host: The host domain to create
243+ :type email_host: string
244+ :return: A domain object or a status code (if the create
245+ request failed)
246+ :rtype int or object
247+ """
248+ data = {
249+ 'email_host': email_host,
250+ }
251+ response = self._http_request('/3.0/domains', data, 'POST')
252+ if response == 201:
253+ return _Domain(self.host, email_host)
254+ else:
255+ return response
256+
257+ def get_domain(self, email_host):
258+ """Return a domain object.
259+
260+ :param email_host: host domain
261+ :type email_host: string
262+ :rtype object
263+ """
264+ return _Domain(self.host, email_host)
265+
266+ def get_lists(self):
267+ """Get a list of all mailing list.
268+
269+ :return: a list of dicts with all mailing lists
270+ :rtype: list
271+ """
272+ response = self._http_request('/3.0/lists')
273+ if 'entries' not in response:
274+ return []
275+ else:
276+ # Return a dict with entries sorted by fqdn_listname
277+ return sorted(response['entries'],
278+ key=itemgetter('fqdn_listname'))
279+
280+ def get_list(self, fqdn_listname):
281+ """Find and return a list object.
282+
283+ :param fqdn_listname: the mailing list address
284+ :type fqdn_listname: string
285+ :rtype: object
286+ """
287+ return _List(self.host, fqdn_listname)
288+
289+ def get_members(self):
290+ """Get a list of all list members.
291+
292+ :return: a list of dicts with the members of all lists
293+ :rtype: list
294+ """
295+ response = self._http_request('/3.0/members')
296+ if 'entries' not in response:
297+ return []
298+ else:
299+ return sorted(response['entries'],
300+ key=itemgetter('self_link'))
301+
302+
303+class _Domain(MailmanRESTClient):
304+ """A domain wrapper for the MailmanRESTClient."""
305+
306+ def __init__(self, host, email_host):
307+ """Connect to host and get list information.
308+
309+ :param host: the host name of the REST API
310+ :type host: string
311+ :param email_host: host domain
312+ :type email_host: string
313+ :rtype: object
314+ """
315+ super(_Domain, self).__init__(host)
316+ self.info = self._http_request('/3.0/domains/' + email_host)
317+
318+ def create_list(self, list_name):
319+ """Create a mailing list and return a list object.
320+
321+ :param list_name: the name of the list to be created
322+ :type list_name: string
323+ :rtype: object
324+ """
325+ fqdn_listname = list_name + '@' + self.info['email_host']
326+ data = {
327+ 'fqdn_listname': fqdn_listname
328+ }
329+ response = self._http_request('/3.0/lists', data, 'POST')
330+ return _List(self.host, fqdn_listname)
331+
332+ def delete_list(self, list_name):
333+ fqdn_listname = list_name + '@' + self.info['email_host']
334+ return self._http_request('/3.0/lists/' + fqdn_listname, None, 'DELETE')
335+
336+
337+class _List(MailmanRESTClient):
338+ """A mailing list wrapper for the MailmanRESTClient."""
339+
340+ def __init__(self, host, fqdn_listname):
341+ """Connect to host and get list information.
342+
343+ :param host: the host name of the REST API
344+ :type host: string
345+ :param fqdn_listname: the mailing list address
346+ :type fqdn_listname: string
347+ :rtype: object
348+ """
349+ super(_List, self).__init__(host)
350+ self.info = self._http_request('/3.0/lists/' + fqdn_listname)
351+
352+ def subscribe(self, address, real_name=None):
353+ """Add an address to a list.
354+
355+ :param address: email address to add to the list.
356+ :type address: string
357+ :param real_name: the real name of the new member
358+ :type real_name: string
359+ """
360+ data = {
361+ 'fqdn_listname': self.info['fqdn_listname'],
362+ 'address': address,
363+ 'real_name': real_name
364+ }
365+ return self._http_request('/3.0/members', data, 'POST')
366+
367+ def unsubscribe(self, address):
368+ """Unsubscribe an address to a list.
369+
370+ :param address: email address to add to the list.
371+ :type address: string
372+ :param real_name: the real name of the new member
373+ :type real_name: string
374+ """
375+ return self._http_request('/3.0/lists/' +
376+ self.info['fqdn_listname'] +
377+ '/member/' +
378+ address,
379+ None,
380+ 'DELETE')
381+
382+ def get_members(self):
383+ """Get a list of all list members.
384+
385+ :return: a list of dicts with all members
386+ :rtype: list
387+ """
388+ response = self._http_request('/3.0/lists/' +
389+ self.info['fqdn_listname'] +
390+ '/roster/members')
391+ if 'entries' not in response:
392+ return []
393+ else:
394+ return sorted(response['entries'],
395+ key=itemgetter('self_link'))
396+