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
=== 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-20 21:14:46 +0000
@@ -0,0 +1,124 @@
1===================
2Mailman REST Client
3===================
4
5 >>> from pprint import pprint
6
7 # The test framework starts out with an example domain, so let's delete
8 # that first.
9 >>> from mailman.interfaces.domain import IDomainManager
10 >>> from zope.component import getUtility
11 >>> domain_manager = getUtility(IDomainManager)
12
13 >>> domain_manager.remove('example.com')
14 <Domain example.com...>
15 >>> transaction.commit()
16
17First let's get an instance of MailmanRESTClient.
18
19 >>> from mailmanclient.rest import MailmanRESTClient, MailmanRESTClientError
20 >>> client = MailmanRESTClient('localhost:8001')
21
22So far there are no lists.
23
24 >>> client.get_lists()
25 []
26
27
28Domains
29=======
30
31In order to add new lists first a new domain has to be added.
32
33 >>> new_domain = client.create_domain('example.com')
34 >>> pprint(new_domain.info)
35 {u'base_url': u'http://example.com',
36 u'contact_address': u'postmaster@example.com',
37 u'description': None,
38 u'email_host': u'example.com',
39 u'http_etag': u'"6b1ccf042e8f76138a0bd37e8509f364da92a5c5"',
40 u'self_link': u'http://localhost:8001/3.0/domains/example.com',
41 u'url_host': u'example.com'}
42
43Later the domain object can be instantiated using get_domain()
44
45 >>> my_domain = client.get_domain('example.com')
46
47
48Mailing lists
49=============
50
51Now let's add s mailing list called 'test-one'.
52
53 >>> new_list = my_domain.create_list('test-one')
54
55Let's add another list and get some information on the list.
56
57 >>> another_list = my_domain.create_list('test-two')
58 >>> pprint(another_list.info)
59 {u'fqdn_listname': u'test-two@example.com',
60 u'host_name': u'example.com',
61 u'http_etag': u'"a05542c9faa07cbe2b8fdf8a1655a2361ab365f2"',
62 u'list_name': u'test-two',
63 u'real_name': u'Test-two',
64 u'self_link': u'http://localhost:8001/3.0/lists/test-two@example.com'}
65
66Later the new list can be instantiated using get_list():
67
68 >>> some_list = client.get_list('test-one@example.com')
69
70The lists have been added and get_lists() returns a list of dicts, sorted
71by fqdn_listname.
72
73 >>> pprint(client.get_lists())
74 [{u'fqdn_listname': u'test-one@example.com',
75 u'host_name': u'example.com',
76 u'http_etag': u'"5e99519ef1b823a52254b77e89bec54fbd17bef0"',
77 u'list_name': u'test-one',
78 u'real_name': u'Test-one',
79 u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com'},
80 {u'fqdn_listname': u'test-two@example.com',
81 u'host_name': u'example.com',
82 u'http_etag': u'"a05542c9faa07cbe2b8fdf8a1655a2361ab365f2"',
83 u'list_name': u'test-two',
84 u'real_name': u'Test-two',
85 u'self_link': u'http://localhost:8001/3.0/lists/test-two@example.com'}]
86
87
88Membership
89==========
90
91Since we now have a list we should add some members to it (.subscribe()
92returns an HTTP status code, ideally 201)
93
94 >>> new_list.subscribe('jack@example.com', 'Jack')
95 201
96 >>> new_list.subscribe('meg@example.com', 'Meg')
97 201
98 >>> another_list.subscribe('jack@example.com', 'Jack')
99 201
100
101We can get a list of all members:
102
103 >>> pprint(client.get_members())
104 [{u'http_etag': u'"320f9e380322cafbbf531c11eab1ec9d38b3bb99"',
105 u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com/member/jack@example.com'},
106 {u'http_etag': u'"cd75b7e93216a022573534d948511edfbfea06cd"',
107 u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com/member/meg@example.com'},
108 {u'http_etag': u'"13399f5ebbab8c474926a7ad0ccfda28d717e398"',
109 u'self_link': u'http://localhost:8001/3.0/lists/test-two@example.com/member/jack@example.com'}]
110
111Or just the members of a specific list:
112
113 >>> pprint(new_list.get_members())
114 [{u'http_etag': u'"320f9e380322cafbbf531c11eab1ec9d38b3bb99"',
115 u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com/member/jack@example.com'},
116 {u'http_etag': u'"cd75b7e93216a022573534d948511edfbfea06cd"',
117 u'self_link': u'http://localhost:8001/3.0/lists/test-one@example.com/member/meg@example.com'}]
118
119After a while Meg decides to unsubscribe from the mailing list (like
120.subscribe() .unsubscribe() returns an HTTP status code, ideally 200).
121
122 >>> new_list.unsubscribe('meg@example.com')
123 200
124
0125
=== added directory 'src/mailmanclient'
=== added file 'src/mailmanclient/__init__.py'
=== added file 'src/mailmanclient/rest.py'
--- src/mailmanclient/rest.py 1970-01-01 00:00:00 +0000
+++ src/mailmanclient/rest.py 2010-07-20 21:14:46 +0000
@@ -0,0 +1,261 @@
1# Copyright (C) 2010 by the Free Software Foundation, Inc.
2#
3# This file is part of GNU Mailman.
4#
5# GNU Mailman is free software: you can redistribute it and/or modify it under
6# the terms of the GNU General Public License as published by the Free
7# Software Foundation, either version 3 of the License, or (at your option)
8# any later version.
9#
10# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13# more details.
14#
15# You should have received a copy of the GNU General Public License along with
16# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
17
18"""A client library for the Mailman REST API."""
19
20
21from __future__ import absolute_import, unicode_literals
22
23__metaclass__ = type
24__all__ = [
25 'MailmanRESTClient',
26 'MailmanRESTClientError',
27 ]
28
29
30import re
31import json
32
33from httplib2 import Http
34from operator import itemgetter
35from urllib import urlencode
36from urllib2 import HTTPError
37
38
39class MailmanRESTClientError(Exception):
40 """An exception thrown by the Mailman REST API client."""
41
42
43class MailmanRESTClient():
44 """A wrapper for the Mailman REST API."""
45
46 def __init__(self, host):
47 """Check and modify the host name.
48
49 :param host: the host name of the REST API
50 :type host: string
51 :return: a MailmanRESTClient object
52 :rtype: objectFirst line should
53 """
54 self.host = host
55 # If there is a trailing slash remove it
56 if self.host[-1] == '/':
57 self.host = self.host[:-1]
58 # If there is no protocol, fall back to http://
59 if self.host[0:4] != 'http':
60 self.host = 'http://' + self.host
61
62 def __repr__(self):
63 return '<MailmanRESTClient: %s>' % self.host
64
65 def _http_request(self, path, data=None, method=None):
66 """Send an HTTP request.
67
68 :param path: the path to send the request to
69 :type path: string
70 :param data: POST oder PUT data to send
71 :type data: dict
72 :param method: the HTTP method; defaults to GET or POST (if
73 data is not None)
74 :type method: string
75 :return: the request content or a status code, depending on the
76 method and if the request was successful
77 :rtype: int, list or dict
78 """
79 url = self.host + path
80 # Include general header information
81 headers = {
82 'User-Agent': 'MailmanRESTClient',
83 'Accept': 'text/plain',
84 }
85 if data is not None:
86 data = urlencode(data)
87 if method is None:
88 if data is None:
89 method = 'GET'
90 else:
91 method = 'POST'
92 method = method.upper()
93 if method == 'POST':
94 headers['Content-type'] = "application/x-www-form-urlencoded"
95 response, content = Http().request(url, method, data, headers)
96 if method == 'GET':
97 if response.status // 100 != 2:
98 return response.status
99 else:
100 return json.loads(content)
101 else:
102 return response.status
103
104 def create_domain(self, email_host):
105 """Create a domain and return a domain object.
106
107 :param email_host: The host domain to create
108 :type email_host: string
109 :return: A domain object or a status code (if the create
110 request failed)
111 :rtype int or object
112 """
113 data = {
114 'email_host': email_host,
115 }
116 response = self._http_request('/3.0/domains', data, 'POST')
117 if response == 201:
118 return _Domain(self.host, email_host)
119 else:
120 return response
121
122 def get_domain(self, email_host):
123 """Return a domain object.
124
125 :param email_host: host domain
126 :type email_host: string
127 :rtype object
128 """
129 return _Domain(self.host, email_host)
130
131 def get_lists(self):
132 """Get a list of all mailing list.
133
134 :return: a list of dicts with all mailing lists
135 :rtype: list
136 """
137 response = self._http_request('/3.0/lists')
138 if 'entries' not in response:
139 return []
140 else:
141 # Return a dict with entries sorted by fqdn_listname
142 return sorted(response['entries'],
143 key=itemgetter('fqdn_listname'))
144
145 def get_list(self, fqdn_listname):
146 """Find and return a list object.
147
148 :param fqdn_listname: the mailing list address
149 :type fqdn_listname: string
150 :rtype: object
151 """
152 return _List(self.host, fqdn_listname)
153
154 def get_members(self):
155 """Get a list of all list members.
156
157 :return: a list of dicts with the members of all lists
158 :rtype: list
159 """
160 response = self._http_request('/3.0/members')
161 if 'entries' not in response:
162 return []
163 else:
164 return sorted(response['entries'],
165 key=itemgetter('self_link'))
166
167
168class _Domain(MailmanRESTClient):
169 """A domain wrapper for the MailmanRESTClient."""
170
171 def __init__(self, host, email_host):
172 """Connect to host and get list information.
173
174 :param host: the host name of the REST API
175 :type host: string
176 :param email_host: host domain
177 :type email_host: string
178 :rtype: object
179 """
180 super(_Domain, self).__init__(host)
181 self.info = self._http_request('/3.0/domains/' + email_host)
182
183 def create_list(self, list_name):
184 """Create a mailing list and return a list object.
185
186 :param list_name: the name of the list to be created
187 :type list_name: string
188 :rtype: object
189 """
190 fqdn_listname = list_name + '@' + self.info['email_host']
191 data = {
192 'fqdn_listname': fqdn_listname
193 }
194 response = self._http_request('/3.0/lists', data, 'POST')
195 return _List(self.host, fqdn_listname)
196
197 def delete_list(self, list_name):
198 fqdn_listname = list_name + '@' + self.info['email_host']
199 return self._http_request('/3.0/lists/' + fqdn_listname, None, 'DELETE')
200
201
202class _List(MailmanRESTClient):
203 """A mailing list wrapper for the MailmanRESTClient."""
204
205 def __init__(self, host, fqdn_listname):
206 """Connect to host and get list information.
207
208 :param host: the host name of the REST API
209 :type host: string
210 :param fqdn_listname: the mailing list address
211 :type fqdn_listname: string
212 :rtype: object
213 """
214 super(_List, self).__init__(host)
215 self.info = self._http_request('/3.0/lists/' + fqdn_listname)
216
217 def subscribe(self, address, real_name=None):
218 """Add an address to a list.
219
220 :param address: email address to add to the list.
221 :type address: string
222 :param real_name: the real name of the new member
223 :type real_name: string
224 """
225 data = {
226 'fqdn_listname': self.info['fqdn_listname'],
227 'address': address,
228 'real_name': real_name
229 }
230 return self._http_request('/3.0/members', data, 'POST')
231
232 def unsubscribe(self, address):
233 """Unsubscribe an address to a list.
234
235 :param address: email address to add to the list.
236 :type address: string
237 :param real_name: the real name of the new member
238 :type real_name: string
239 """
240 return self._http_request('/3.0/lists/' +
241 self.info['fqdn_listname'] +
242 '/member/' +
243 address,
244 None,
245 'DELETE')
246
247 def get_members(self):
248 """Get a list of all list members.
249
250 :return: a list of dicts with all members
251 :rtype: list
252 """
253 response = self._http_request('/3.0/lists/' +
254 self.info['fqdn_listname'] +
255 '/roster/members')
256 if 'entries' not in response:
257 return []
258 else:
259 return sorted(response['entries'],
260 key=itemgetter('self_link'))
261