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

Proposed by Florian Fuchs on 2010-06-25
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 2010-07-07 Approve on 2010-12-26
Florian Fuchs Needs Resubmitting on 2010-07-28
Mailman Coders 2010-06-25 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.
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 on 2010-07-07
6916. By Florian Fuchs on 2010-07-07

added _Domain and _List classes, fixed code style issues

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 on 2010-07-15
6917. By Florian Fuchs on 2010-07-11

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

6918. By Florian Fuchs on 2010-07-15

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

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 on 2010-07-20
6919. By Florian Fuchs on 2010-07-20

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

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
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+