Merge lp:~robru/friends/simplify-contacts into lp:friends

Proposed by Robert Bruce Park
Status: Merged
Approved by: Ken VanDine
Approved revision: 236
Merged at revision: 225
Proposed branch: lp:~robru/friends/simplify-contacts
Merge into: lp:friends
Diff against target: 1197 lines (+410/-468)
14 files modified
friends/protocols/facebook.py (+21/-54)
friends/protocols/identica.py (+1/-0)
friends/protocols/linkedin.py (+24/-32)
friends/protocols/twitter.py (+27/-59)
friends/tests/test_dispatcher.py (+15/-12)
friends/tests/test_facebook.py (+141/-51)
friends/tests/test_flickr.py (+2/-1)
friends/tests/test_foursquare.py (+2/-1)
friends/tests/test_identica.py (+30/-47)
friends/tests/test_instagram.py (+2/-1)
friends/tests/test_linkedin.py (+41/-71)
friends/tests/test_protocols.py (+2/-1)
friends/tests/test_twitter.py (+31/-50)
friends/utils/base.py (+71/-88)
To merge this branch: bzr merge lp:~robru/friends/simplify-contacts
Reviewer Review Type Date Requested Status
Ken VanDine Approve
PS Jenkins bot (community) continuous-integration Approve
Robert Bruce Park Approve
Review via email: mp+175968@code.launchpad.net

Commit message

Vast simplification of contact logic.

Description of the change

Great simplification of our messy contacts implementation. Lots of big changes here, but here are some highlights:

* Stop creating VCards, serializing them into strings, and then parsing them back into EDS Contact objects. Just create the Contact directly.

* Stop iterating over all EDS address books, looking for the one with the right display name. Simply request the correct address book by ID the first time. Also cache this instance through the lifetime of the script, so it doesn't need to be polled for continuously.

* Streamline a lot of the _create_contact logic, which was highly reminiscent of the old Gwibber code, where json-ish data structures were being schlepped around and copied from one form into another, for no apparent reason.

To post a comment you must log in.
Revision history for this message
Robert Bruce Park (robru) wrote :

Ok, I'm pretty happy with this at this point. A lot of mysterious methods are gone, test coverage is good, and the major inefficiencies have been streamlined. If you could review this on Monday, Ken, that'd be super.

review: Approve
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :

PASSED: Continuous integration, rev:236
http://jenkins.qa.ubuntu.com/job/friends-ci/50/
Executed test runs:
    SUCCESS: http://jenkins.qa.ubuntu.com/job/friends-saucy-amd64-ci/7

Click here to trigger a rebuild:
http://s-jenkins:8080/job/friends-ci/50/rebuild

review: Approve (continuous-integration)
Revision history for this message
Ken VanDine (ken-vandine) wrote :

Looks like it works well, thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'friends/protocols/facebook.py'
2--- friends/protocols/facebook.py 2013-07-17 22:15:56 +0000
3+++ friends/protocols/facebook.py 2013-07-20 03:25:30 +0000
4@@ -326,68 +326,35 @@
5 else:
6 raise FriendsError(str(response))
7
8- def _fetch_contacts(self):
9- """Retrieve a list of up to 1,000 Facebook friends."""
10- limit = 1000
11- access_token = self._get_access_token()
12- url = ME_URL + '/friends'
13- params = dict(
14- access_token=access_token,
15- limit=limit)
16- return self._follow_pagination(url, params, limit)
17-
18- def _fetch_contact(self, contact_id):
19- """Fetch the full, individual contact info."""
20- access_token = self._get_access_token()
21- url = API_BASE.format(id=contact_id)
22- params = dict(access_token=access_token)
23- return Downloader(url, params).get_json()
24-
25- def _create_contact(self, contact_json):
26- """Build a VCard based on a dict representation of a contact."""
27-
28- user_id = contact_json.get('id')
29- user_fullname = contact_json.get('name')
30- user_nickname = contact_json.get('username')
31- user_link = contact_json.get('link')
32- gender = contact_json.get('gender')
33-
34- attrs = {}
35- attrs['facebook-id'] = user_id
36- attrs['facebook-name'] = user_fullname
37- attrs['X-URIS'] = user_link
38- attrs['X-FOLKS-WEB-SERVICES-IDS'] = {
39- 'jabber':'-{}@chat.facebook.com'.format(user_id),
40- 'remote-full-name':user_fullname,
41- 'facebook-id': user_id}
42- if gender is not None:
43- attrs['X-GENDER'] = gender
44-
45- return super()._create_contact(user_fullname, user_nickname, attrs)
46-
47 @feature
48 def contacts(self):
49- contacts = self._fetch_contacts()
50- log.debug('Size of the contacts returned {}'.format(len(contacts)))
51- source = self._get_eds_source()
52+ contacts = self._follow_pagination(
53+ url=ME_URL + '/friends',
54+ params=dict(access_token=self._get_access_token(), limit=1000),
55+ limit=1000)
56+ log.debug('Found {} contacts'.format(len(contacts)))
57
58 for contact in contacts:
59- if self._previously_stored_contact(
60- source, 'facebook-id', contact['id']):
61+ contact_id = contact.get('id')
62+ if contact_id is None or self._previously_stored_contact(contact_id):
63 continue
64- log.debug(
65- 'Fetch full contact info for {} and id {}'.format(
66- contact['name'], contact['id']))
67- full_contact = self._fetch_contact(contact['id'])
68- eds_contact = self._create_contact(full_contact)
69- self._push_to_eds(eds_contact)
70+ full_contact = Downloader(
71+ url=API_BASE.format(id=contact_id),
72+ params=dict(access_token=self._get_access_token())).get_json()
73+ self._push_to_eds({
74+ 'facebook-id': contact_id,
75+ 'facebook-name': full_contact.get('name'),
76+ 'facebook-nick': full_contact.get('username'),
77+ 'X-URIS': full_contact.get('link'),
78+ 'X-GENDER': full_contact.get('gender'),
79+ 'X-FOLKS-WEB-SERVICES-IDS': {
80+ 'jabber': '-{}@chat.facebook.com'.format(contact_id),
81+ 'remote-full-name': full_contact.get('name'),
82+ 'facebook-id': contact_id,
83+ }})
84
85 return len(contacts)
86
87- def delete_contacts(self):
88- source = self._get_eds_source()
89- return self._delete_service_contacts(source)
90-
91
92 class PostIdCache(JsonCache):
93 """Persist most-recent timestamps as JSON."""
94
95=== modified file 'friends/protocols/identica.py'
96--- friends/protocols/identica.py 2013-04-03 03:47:39 +0000
97+++ friends/protocols/identica.py 2013-07-20 03:25:30 +0000
98@@ -39,6 +39,7 @@
99 _favorite = _api_base.format(endpoint='favorites/create/{}')
100 _del_favorite = _api_base.format(endpoint='favorites/destroy/{}')
101
102+ _user_home = 'https://identi.ca/{user_id}'
103 _tweet_permalink = 'http://identi.ca/notice/{tweet_id}'
104
105 def _whoami(self, authdata):
106
107=== modified file 'friends/protocols/linkedin.py'
108--- friends/protocols/linkedin.py 2013-07-17 22:25:42 +0000
109+++ friends/protocols/linkedin.py 2013-07-20 03:25:30 +0000
110@@ -26,12 +26,16 @@
111 from friends.utils.base import Base, feature
112 from friends.utils.http import Downloader
113 from friends.utils.time import iso8601utc
114-from friends.errors import FriendsError
115
116
117 log = logging.getLogger(__name__)
118
119
120+def make_fullname(firstName=None, lastName=None, **ignored):
121+ """Converts dict(firstName='Bob', lastName='Loblaw') into 'Bob Loblaw'."""
122+ return ' '.join(name for name in (firstName, lastName) if name)
123+
124+
125 class LinkedIn(Base):
126 _api_base = ('https://api.linkedin.com/v1/{endpoint}?format=json' +
127 '&secure-urls=true&oauth2_access_token={token}')
128@@ -44,7 +48,7 @@
129 token=self._get_access_token())
130 result = Downloader(url).get_json()
131 self._account.user_id = result.get('id')
132- self._account.user_name = '{firstName} {lastName}'.format(**result)
133+ self._account.user_name = make_fullname(**result)
134
135 def _publish_entry(self, entry, stream='messages'):
136 """Publish a single update into the Dee.SharedModel."""
137@@ -52,7 +56,7 @@
138
139 content = entry.get('updateContent', {})
140 person = content.get('person', {})
141- name = '{firstName} {lastName}'.format(**person)
142+ name = make_fullname(**person)
143 person_id = person.get('id', '')
144 status = person.get('currentStatus')
145 picture = person.get('pictureUrl', '')
146@@ -98,40 +102,28 @@
147 """Gather and publish all incoming messages."""
148 return self.home()
149
150- def _create_contact(self, connection_json):
151- """Build a VCard based on a dict representation of a contact."""
152- user_id = connection_json.get('id', '')
153-
154- user_fullname = '{firstName} {lastName}'.format(**connection_json)
155- user_link = connection_json.get(
156- 'siteStandardProfileRequest', {}).get('url', '')
157-
158- attrs = { 'linkedin-id': user_id,
159- 'linkedin-name': user_fullname,
160- 'X-URIS': user_link }
161-
162- return super()._create_contact(user_fullname, None, attrs)
163-
164 @feature
165 def contacts(self):
166 """Retrieve a list of up to 500 LinkedIn connections."""
167 # http://developer.linkedin.com/documents/connections-api
168- url = self._api_base.format(
169- endpoint='people/~/connections',
170- token=self._get_access_token())
171- result = Downloader(url).get_json()
172- connections = result.get('values', [])
173- source = self._get_eds_source()
174+ connections = Downloader(
175+ url=self._api_base.format(
176+ endpoint='people/~/connections',
177+ token=self._get_access_token())
178+ ).get_json().get('values', [])
179
180 for connection in connections:
181- connection_id = connection.get('id')
182- if connection_id != 'private':
183- if not self._previously_stored_contact(
184- source, 'linkedin-id', connection_id):
185- self._push_to_eds(self._create_contact(connection))
186+ connection_id = connection.get('id', 'private')
187+ fullname = make_fullname(**connection)
188+ if connection_id != 'private' and not self._previously_stored_contact(connection_id):
189+ self._push_to_eds({
190+ 'linkedin-id': connection_id,
191+ 'linkedin-name': fullname,
192+ 'X-URIS': connection.get(
193+ 'siteStandardProfileRequest', {}).get('url'),
194+ 'X-FOLKS-WEB-SERVICES-IDS': {
195+ 'remote-full-name': fullname,
196+ 'linkedin-id': connection_id,
197+ }})
198
199 return len(connections)
200-
201- def delete_contacts(self):
202- source = self._get_eds_source()
203- return self._delete_service_contacts(source)
204
205=== modified file 'friends/protocols/twitter.py'
206--- friends/protocols/twitter.py 2013-07-17 22:15:56 +0000
207+++ friends/protocols/twitter.py 2013-07-20 03:25:30 +0000
208@@ -61,7 +61,8 @@
209 _favorite = _api_base.format(endpoint='favorites/create')
210 _del_favorite = _api_base.format(endpoint='favorites/destroy')
211
212- _tweet_permalink = 'https://twitter.com/{user_id}/status/{tweet_id}'
213+ _user_home = 'https://twitter.com/{user_id}'
214+ _tweet_permalink = _user_home + '/status/{tweet_id}'
215
216 def __init__(self, account):
217 super().__init__(account)
218@@ -347,68 +348,35 @@
219 self._publish_tweet(tweet, stream='search/{}'.format(query))
220 return self._get_n_rows()
221
222-# https://dev.twitter.com/docs/api/1.1/get/friends/ids
223- def _getfriendsids(self):
224- """Get a list of the twitter id's of our twitter friends."""
225- url = self._api_base.format(endpoint="friends/ids")
226- response = self._get_url(url)
227-
228- try:
229- # Twitter
230- return response["ids"]
231- except TypeError:
232- # Identica
233- return response
234-
235-# https://dev.twitter.com/docs/api/1.1/get/users/show
236- def _showuser(self, uid):
237- """Get all the information about a twitter user."""
238- url = self._api_base.format(
239- endpoint='users/show') + '?user_id={}'.format(uid)
240- return self._get_url(url)
241-
242- def _create_contact(self, userdata):
243- """Build a VCard based on a dict representation of a contact."""
244-
245- if userdata.get('error'):
246- raise FriendsError(userdata)
247-
248- user_fullname = userdata['name']
249- user_nickname = userdata['screen_name']
250-
251- attrs = {}
252- attrs['twitter-id'] = str(userdata['id'])
253- attrs['twitter-name'] = user_fullname
254- attrs['X-URIS'] = 'https://twitter.com/{}'.format(user_nickname)
255- attrs['X-FOLKS-WEB-SERVICES-IDS'] = {
256- 'remote-full-name': user_fullname,
257- 'twitter-id': str(userdata['id']),
258- }
259-
260- return super()._create_contact(user_fullname, user_nickname, attrs)
261-
262 @feature
263 def contacts(self):
264- contacts = self._getfriendsids()
265- log.debug('Size of the contacts returned {}'.format(len(contacts)))
266- source = self._get_eds_source()
267-
268- for contact in contacts:
269- twitterid = str(contact)
270- if self._previously_stored_contact(source, 'twitter-id', twitterid):
271- continue
272- full_contact = self._showuser(twitterid)
273- try:
274- eds_contact = self._create_contact(full_contact)
275- except FriendsError:
276- continue
277- self._push_to_eds(eds_contact)
278+ # https://dev.twitter.com/docs/api/1.1/get/friends/ids
279+ contacts = self._get_url(self._api_base.format(endpoint='friends/ids'))
280+ # Twitter uses a dict with 'ids' key, Identica returns the ids directly.
281+ with ignored(TypeError):
282+ contacts = contacts['ids']
283+
284+ log.debug('Found {} contacts'.format(len(contacts)))
285+
286+ for contact_id in contacts:
287+ contact_id = str(contact_id)
288+ if not self._previously_stored_contact(contact_id):
289+ # https://dev.twitter.com/docs/api/1.1/get/users/show
290+ full_contact = self._get_url(url=self._api_base.format(
291+ endpoint='users/show') + '?user_id=' + contact_id)
292+ user_fullname = full_contact.get('name')
293+ user_nickname = full_contact.get('screen_name')
294+ self._push_to_eds({
295+ '{}-id'.format(self._name): contact_id,
296+ '{}-name'.format(self._name): user_fullname,
297+ '{}-nick'.format(self._name): user_nickname,
298+ 'X-URIS': self._user_home.format(user_id=user_nickname),
299+ 'X-FOLKS-WEB-SERVICES-IDS': {
300+ 'remote-full-name': user_fullname,
301+ '{}-id'.format(self._name): contact_id,
302+ }})
303 return len(contacts)
304
305- def delete_contacts(self):
306- source = self._get_eds_source()
307- return self._delete_service_contacts(source)
308-
309
310 class TweetIdCache(JsonCache):
311 """Persist most-recent tweet_ids as JSON."""
312
313=== modified file 'friends/tests/test_dispatcher.py'
314--- friends/tests/test_dispatcher.py 2013-04-03 04:04:56 +0000
315+++ friends/tests/test_dispatcher.py 2013-07-20 03:25:30 +0000
316@@ -184,23 +184,26 @@
317
318 def test_get_features(self):
319 self.assertEqual(json.loads(self.dispatcher.GetFeatures('facebook')),
320- ['contacts', 'delete', 'home', 'like', 'receive',
321- 'search', 'send', 'send_thread', 'unlike', 'upload',
322- 'wall'])
323+ ['contacts', 'delete', 'delete_contacts',
324+ 'home', 'like', 'receive', 'search', 'send',
325+ 'send_thread', 'unlike', 'upload', 'wall'])
326 self.assertEqual(json.loads(self.dispatcher.GetFeatures('twitter')),
327- ['contacts', 'delete', 'follow', 'home', 'like',
328- 'list', 'lists', 'mentions', 'private', 'receive',
329- 'retweet', 'search', 'send', 'send_private',
330- 'send_thread', 'tag', 'unfollow', 'unlike', 'user'])
331+ ['contacts', 'delete', 'delete_contacts',
332+ 'follow', 'home', 'like', 'list', 'lists',
333+ 'mentions', 'private', 'receive', 'retweet',
334+ 'search', 'send', 'send_private',
335+ 'send_thread', 'tag', 'unfollow', 'unlike',
336+ 'user'])
337 self.assertEqual(json.loads(self.dispatcher.GetFeatures('identica')),
338- ['contacts', 'delete', 'follow', 'home', 'like',
339- 'mentions', 'private', 'receive', 'retweet',
340- 'search', 'send', 'send_private', 'send_thread',
341+ ['contacts', 'delete', 'delete_contacts',
342+ 'follow', 'home', 'like', 'mentions',
343+ 'private', 'receive', 'retweet', 'search',
344+ 'send', 'send_private', 'send_thread',
345 'unfollow', 'unlike', 'user'])
346 self.assertEqual(json.loads(self.dispatcher.GetFeatures('flickr')),
347- ['receive', 'upload'])
348+ ['delete_contacts', 'receive', 'upload'])
349 self.assertEqual(json.loads(self.dispatcher.GetFeatures('foursquare')),
350- ['receive'])
351+ ['delete_contacts', 'receive'])
352
353 @mock.patch('friends.service.dispatcher.logging')
354 def test_urlshorten_already_shortened(self, logging_mock):
355
356=== modified file 'friends/tests/test_facebook.py'
357--- friends/tests/test_facebook.py 2013-07-17 22:15:56 +0000
358+++ friends/tests/test_facebook.py 2013-07-20 03:25:30 +0000
359@@ -26,13 +26,13 @@
360 import unittest
361 import shutil
362
363-from gi.repository import GLib
364+from gi.repository import GLib, EDataServer
365 from pkg_resources import resource_filename
366
367 from friends.protocols.facebook import Facebook
368 from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
369 from friends.tests.mocks import TestModel, mock
370-from friends.tests.mocks import EDSBookClientMock, EDSSource, EDSRegistry
371+from friends.tests.mocks import EDSBookClientMock, EDSRegistry
372 from friends.errors import ContactsError, FriendsError, AuthorizationError
373 from friends.utils.cache import JsonCache
374
375@@ -57,8 +57,9 @@
376 def test_features(self):
377 # The set of public features.
378 self.assertEqual(Facebook.get_features(),
379- ['contacts', 'delete', 'home', 'like', 'receive', 'search', 'send',
380- 'send_thread', 'unlike', 'upload', 'wall'])
381+ ['contacts', 'delete', 'delete_contacts', 'home',
382+ 'like', 'receive', 'search', 'send', 'send_thread',
383+ 'unlike', 'upload', 'wall'])
384
385 @mock.patch('friends.utils.authentication.manager')
386 @mock.patch('friends.utils.authentication.Accounts')
387@@ -474,23 +475,66 @@
388 params=dict(access_token='face'))
389 unpublish.assert_called_once_with('post_id')
390
391- @mock.patch('friends.utils.http.Soup.Message',
392- FakeSoupMessage('friends.tests.data', 'facebook-contacts.dat'))
393- @mock.patch('friends.protocols.facebook.Facebook._login',
394- return_value=True)
395- def test_fetch_contacts(self, *mocks):
396- # Receive the users friends.
397- results = self.protocol._fetch_contacts()
398- self.assertEqual(len(results), 8)
399- self.assertEqual(results[7]['name'], 'John Smith')
400- self.assertEqual(results[7]['id'], '444444')
401+ @mock.patch('friends.protocols.facebook.Downloader')
402+ def test_contacts(self, downloader):
403+ downloader().get_json.return_value = dict(
404+ name='Joe Blow', username='jblow', link='example.com', gender='male')
405+ downloader.reset_mock()
406+ self.protocol._get_access_token = mock.Mock(return_value='broken')
407+ follow = self.protocol._follow_pagination = mock.Mock(
408+ return_value=[dict(id='contact1'), dict(id='contact2')])
409+ prev = self.protocol._previously_stored_contact = mock.Mock(return_value=False)
410+ push = self.protocol._push_to_eds = mock.Mock()
411+ self.assertEqual(self.protocol.contacts(), 2)
412+ follow.assert_called_once_with(
413+ params={'access_token': 'broken', 'limit': 1000},
414+ url='https://graph.facebook.com/me/friends',
415+ limit=1000)
416+ self.assertEqual(
417+ prev.call_args_list,
418+ [mock.call('contact1'), mock.call('contact2')])
419+ self.assertEqual(
420+ downloader.call_args_list,
421+ [mock.call(url='https://graph.facebook.com/contact1',
422+ params={'access_token': 'broken'}),
423+ mock.call(url='https://graph.facebook.com/contact2',
424+ params={'access_token': 'broken'})])
425+ self.assertEqual(
426+ push.call_args_list,
427+ [mock.call({
428+ 'X-FOLKS-WEB-SERVICES-IDS': {
429+ 'jabber': '-contact1@chat.facebook.com',
430+ 'facebook-id': 'contact1',
431+ 'remote-full-name': 'Joe Blow'},
432+ 'X-GENDER': 'male',
433+ 'facebook-name': 'Joe Blow',
434+ 'facebook-id': 'contact1',
435+ 'X-URIS': 'example.com',
436+ 'facebook-nick': 'jblow'}),
437+ mock.call({
438+ 'X-FOLKS-WEB-SERVICES-IDS': {
439+ 'jabber': '-contact2@chat.facebook.com',
440+ 'facebook-id': 'contact2',
441+ 'remote-full-name': 'Joe Blow'},
442+ 'X-GENDER': 'male',
443+ 'facebook-name': 'Joe Blow',
444+ 'facebook-id': 'contact2',
445+ 'X-URIS': 'example.com',
446+ 'facebook-nick': 'jblow'})])
447
448 def test_create_contact(self, *mocks):
449 # Receive the users friends.
450- bare_contact = {'name': 'Lucy Baron',
451- 'id': '555555555',
452- 'username': 'lucy.baron5',
453- 'link': 'http:www.facebook.com/lucy.baron5'}
454+ bare_contact = {
455+ 'facebook-id': '555555555',
456+ 'facebook-name': 'Lucy Baron',
457+ 'facebook-nick': 'lucy.baron5',
458+ 'X-URIS': 'http:www.facebook.com/lucy.baron5',
459+ 'X-GENDER': 'female',
460+ 'X-FOLKS-WEB-SERVICES-IDS': {
461+ 'jabber': '-555555555@chat.facebook.com',
462+ 'remote-full-name': 'Lucy Baron',
463+ 'facebook-id': '555555555',
464+ }}
465 eds_contact = self.protocol._create_contact(bare_contact)
466 facebook_id_attr = eds_contact.get_attribute('facebook-id')
467 self.assertEqual(facebook_id_attr.get_value(), '555555555')
468@@ -521,7 +565,7 @@
469 # Finally test to ensure all key value pairs were tested
470 self.assertTrue(test_jabber and test_remote_name and test_facebook_id)
471
472- @mock.patch('friends.utils.base.Base._get_eds_source',
473+ @mock.patch('friends.utils.base.Base._prepare_eds_connections',
474 return_value=True)
475 @mock.patch('gi.repository.EBook.BookClient.new',
476 return_value=EDSBookClientMock())
477@@ -530,54 +574,100 @@
478 'id': '555555555',
479 'username': 'lucy.baron5',
480 'link': 'http:www.facebook.com/lucy.baron5'}
481- eds_contact = self.protocol._create_contact(bare_contact)
482 self.protocol._address_book = 'test-address-book'
483+ client = self.protocol._book_client = mock.Mock()
484+ client.add_contact_sync.return_value = True
485 # Implicitely fail test if the following raises any exceptions
486- self.protocol._push_to_eds(eds_contact)
487+ self.protocol._push_to_eds(bare_contact)
488
489- @mock.patch('friends.utils.base.Base._get_eds_source',
490- return_value=None)
491- @mock.patch('friends.utils.base.Base._create_eds_source',
492+ @mock.patch('friends.utils.base.Base._prepare_eds_connections',
493 return_value=None)
494 def test_unsuccessfull_push_to_eds(self, *mocks):
495 bare_contact = {'name': 'Lucy Baron',
496 'id': '555555555',
497 'username': 'lucy.baron5',
498 'link': 'http:www.facebook.com/lucy.baron5'}
499- eds_contact = self.protocol._create_contact(bare_contact)
500 self.protocol._address_book = 'test-address-book'
501+ client = self.protocol._book_client = mock.Mock()
502+ client.add_contact_sync.return_value = False
503 self.assertRaises(
504 ContactsError,
505 self.protocol._push_to_eds,
506- eds_contact,
507+ bare_contact,
508 )
509
510- @mock.patch('gi.repository.EDataServer.Source.new',
511- return_value=EDSSource('foo', 'bar'))
512- def test_create_eds_source(self, *mocks):
513- regmock = self.protocol._source_registry = mock.Mock()
514- regmock.ref_source = lambda x: x
515-
516- result = self.protocol._create_eds_source()
517- self.assertEqual(result, 'test-source-uid')
518-
519- @mock.patch('gi.repository.EBook.BookClient.new',
520+ @mock.patch('gi.repository.EBook.BookClient.connect_sync',
521 return_value=EDSBookClientMock())
522 def test_successful_previously_stored_contact(self, *mocks):
523- result = self.protocol._previously_stored_contact(
524- True, 'facebook-id', '11111')
525+ result = self.protocol._previously_stored_contact('11111')
526 self.assertEqual(result, True)
527
528- def test_successful_get_eds_source(self, *mocks):
529- class FakeSource:
530- def get_display_name(self):
531- return 'test-facebook-contacts'
532- def get_uid(self):
533- return 1345245
534-
535- reg_mock = self.protocol._source_registry = mock.Mock()
536- reg_mock.list_sources.return_value = [FakeSource()]
537- reg_mock.ref_source = lambda x: x
538- self.protocol._address_book = 'test-facebook-contacts'
539- result = self.protocol._get_eds_source()
540- self.assertEqual(result, 1345245)
541+ def test_first_run_prepare_eds_connections(self):
542+ self.protocol._name = 'testsuite'
543+ self.assertIsNone(self.protocol._address_book_name)
544+ self.assertIsNone(self.protocol._eds_source_registry)
545+ self.assertIsNone(self.protocol._eds_source)
546+ self.assertIsNone(self.protocol._book_client)
547+ self.protocol._prepare_eds_connections()
548+ self.assertEqual(self.protocol._address_book_name,
549+ 'friends-testsuite-contacts')
550+ self.assertEqual(self.protocol._eds_source.get_display_name(),
551+ 'friends-testsuite-contacts')
552+ self.assertEqual(self.protocol._eds_source.get_uid(),
553+ 'friends-testsuite-contacts')
554+ self.protocol.delete_contacts()
555+
556+ @mock.patch('gi.repository.EDataServer.SourceRegistry')
557+ @mock.patch('gi.repository.EDataServer.Source')
558+ @mock.patch('gi.repository.EBook.BookClient')
559+ def test_mocked_prepare_eds_connections(self, client, source, registry):
560+ self.protocol._name = 'testsuite'
561+ self.assertIsNone(self.protocol._address_book_name)
562+ self.protocol._prepare_eds_connections()
563+ self.protocol._prepare_eds_connections() # Second time harmlessly ignored
564+ self.assertEqual(self.protocol._address_book_name,
565+ 'friends-testsuite-contacts')
566+ registry.new_sync.assert_called_once_with(None)
567+ self.assertEqual(self.protocol._eds_source_registry,
568+ registry.new_sync())
569+ registry.new_sync().ref_source.assert_called_once_with(
570+ 'friends-testsuite-contacts')
571+ self.assertEqual(self.protocol._eds_source,
572+ registry.new_sync().ref_source())
573+ client.connect_sync.assert_called_once_with(
574+ registry.new_sync().ref_source(), None)
575+ self.assertEqual(self.protocol._book_client,
576+ client.connect_sync())
577+
578+ @mock.patch('gi.repository.EDataServer.SourceRegistry')
579+ @mock.patch('gi.repository.EDataServer.Source')
580+ @mock.patch('gi.repository.EBook.BookClient')
581+ def test_create_new_eds_book(self, client, source, registry):
582+ self.protocol._name = 'testsuite'
583+ self.assertIsNone(self.protocol._address_book_name)
584+ registry.new_sync().ref_source.return_value = None
585+ registry.reset_mock()
586+ self.protocol._prepare_eds_connections()
587+ self.protocol._prepare_eds_connections() # Second time harmlessly ignored
588+ self.assertEqual(self.protocol._address_book_name,
589+ 'friends-testsuite-contacts')
590+ registry.new_sync.assert_called_once_with(None)
591+ self.assertEqual(self.protocol._eds_source_registry,
592+ registry.new_sync())
593+ registry.new_sync().ref_source.assert_called_once_with(
594+ 'friends-testsuite-contacts')
595+ source.new_with_uid.assert_called_once_with(
596+ 'friends-testsuite-contacts', None)
597+ self.assertEqual(self.protocol._eds_source,
598+ source.new_with_uid())
599+ source.new_with_uid().set_display_name.assert_called_once_with(
600+ 'friends-testsuite-contacts')
601+ source.new_with_uid().set_parent.assert_called_once_with('local-stub')
602+ source.new_with_uid().get_extension.assert_called_once_with(
603+ EDataServer.SOURCE_EXTENSION_ADDRESS_BOOK)
604+ registry.new_sync().commit_source_sync.assert_called_once_with(
605+ source.new_with_uid(), None)
606+ client.connect_sync.assert_called_once_with(
607+ source.new_with_uid(), None)
608+ self.assertEqual(self.protocol._book_client,
609+ client.connect_sync())
610
611=== modified file 'friends/tests/test_flickr.py'
612--- friends/tests/test_flickr.py 2013-06-18 22:00:44 +0000
613+++ friends/tests/test_flickr.py 2013-07-20 03:25:30 +0000
614@@ -49,7 +49,8 @@
615
616 def test_features(self):
617 # The set of public features.
618- self.assertEqual(Flickr.get_features(), ['receive', 'upload'])
619+ self.assertEqual(Flickr.get_features(),
620+ ['delete_contacts', 'receive', 'upload'])
621
622 @mock.patch('friends.utils.http.Soup.Message',
623 FakeSoupMessage('friends.tests.data', 'flickr-nophotos.dat'))
624
625=== modified file 'friends/tests/test_foursquare.py'
626--- friends/tests/test_foursquare.py 2013-06-18 22:00:44 +0000
627+++ friends/tests/test_foursquare.py 2013-07-20 03:25:30 +0000
628@@ -48,7 +48,8 @@
629
630 def test_features(self):
631 # The set of public features.
632- self.assertEqual(FourSquare.get_features(), ['receive'])
633+ self.assertEqual(FourSquare.get_features(),
634+ ['delete_contacts', 'receive'])
635
636 @mock.patch('friends.utils.authentication.manager')
637 @mock.patch('friends.utils.authentication.Accounts')
638
639=== modified file 'friends/tests/test_identica.py'
640--- friends/tests/test_identica.py 2013-04-17 06:13:49 +0000
641+++ friends/tests/test_identica.py 2013-07-20 03:25:30 +0000
642@@ -221,24 +221,6 @@
643 get_url.assert_called_with(
644 'http://identi.ca/api/search.json?q=hello')
645
646- def test_getfriendsids(self):
647- get_url = self.protocol._get_url = mock.Mock(return_value=[1,2,3])
648- ids = self.protocol._getfriendsids()
649-
650- get_url.assert_called_with(
651- 'http://identi.ca/api/friends/ids.json'
652- )
653- self.assertEqual(ids, [1,2,3])
654-
655- def test_showuser(self):
656- get_url = self.protocol._get_url = mock.Mock(return_value={"name":"Alice"})
657- userdata = self.protocol._showuser(1)
658-
659- get_url.assert_called_with(
660- 'http://identi.ca/api/users/show.json?user_id=1'
661- )
662- self.assertEqual(userdata, {"name":"Alice"})
663-
664 def test_like(self):
665 get_url = self.protocol._get_url = mock.Mock()
666 inc_cell = self.protocol._inc_cell = mock.Mock()
667@@ -265,32 +247,33 @@
668 'http://identi.ca/api/favorites/destroy/1234.json',
669 dict(id='1234'))
670
671- def test_create_contact(self, *mocks):
672- # Receive the users friends.
673- bare_contact = {'name': 'Alice Bob',
674- 'screen_name': 'alice_bob',
675- 'id': 13579}
676-
677- eds_contact = self.protocol._create_contact(bare_contact)
678- twitter_id_attr = eds_contact.get_attribute('twitter-id')
679- self.assertEqual(twitter_id_attr.get_value(), '13579')
680- twitter_name_attr = eds_contact.get_attribute('twitter-name')
681- self.assertEqual(twitter_name_attr.get_value(), 'Alice Bob')
682- web_service_addrs = eds_contact.get_attribute('X-FOLKS-WEB-SERVICES-IDS')
683- params= web_service_addrs.get_params()
684- self.assertEqual(len(params), 2)
685-
686- test_remote_name = False
687- test_twitter_id = False
688-
689- for p in params:
690- if p.get_name() == 'remote-full-name':
691- self.assertEqual(len(p.get_values()), 1)
692- self.assertEqual(p.get_values()[0], 'Alice Bob')
693- test_remote_name = True
694- if p.get_name() == 'twitter-id':
695- self.assertEqual(len(p.get_values()), 1)
696- self.assertEqual(p.get_values()[0], '13579')
697- test_twitter_id = True
698-
699- self.assertTrue(test_remote_name and test_twitter_id)
700+ def test_contacts(self):
701+ get = self.protocol._get_url = mock.Mock(
702+ return_value=dict(ids=[1,2],name='Bob',screen_name='bobby'))
703+ prev = self.protocol._previously_stored_contact = mock.Mock(return_value=False)
704+ push = self.protocol._push_to_eds = mock.Mock()
705+ self.assertEqual(self.protocol.contacts(), 2)
706+ self.assertEqual(
707+ get.call_args_list,
708+ [mock.call('http://identi.ca/api/friends/ids.json'),
709+ mock.call(url='http://identi.ca/api/users/show.json?user_id=1'),
710+ mock.call(url='http://identi.ca/api/users/show.json?user_id=2')])
711+ self.assertEqual(
712+ prev.call_args_list,
713+ [mock.call('1'), mock.call('2')])
714+ self.assertEqual(
715+ push.call_args_list,
716+ [mock.call({'identica-id': '1',
717+ 'X-FOLKS-WEB-SERVICES-IDS': {
718+ 'identica-id': '1',
719+ 'remote-full-name': 'Bob'},
720+ 'X-URIS': 'https://identi.ca/bobby',
721+ 'identica-name': 'Bob',
722+ 'identica-nick': 'bobby'}),
723+ mock.call({'identica-id': '2',
724+ 'X-FOLKS-WEB-SERVICES-IDS': {
725+ 'identica-id': '2',
726+ 'remote-full-name': 'Bob'},
727+ 'X-URIS': 'https://identi.ca/bobby',
728+ 'identica-name': 'Bob',
729+ 'identica-nick': 'bobby'})])
730
731=== modified file 'friends/tests/test_instagram.py'
732--- friends/tests/test_instagram.py 2013-07-08 20:54:34 +0000
733+++ friends/tests/test_instagram.py 2013-07-20 03:25:30 +0000
734@@ -54,7 +54,8 @@
735 def test_features(self):
736 # The set of public features.
737 self.assertEqual(Instagram.get_features(),
738- ['home', 'like', 'receive', 'send_thread', 'unlike'])
739+ ['delete_contacts', 'home', 'like', 'receive',
740+ 'send_thread', 'unlike'])
741
742 @mock.patch('friends.utils.authentication.manager')
743 @mock.patch('friends.utils.authentication.Accounts')
744
745=== modified file 'friends/tests/test_linkedin.py'
746--- friends/tests/test_linkedin.py 2013-07-18 03:35:03 +0000
747+++ friends/tests/test_linkedin.py 2013-07-20 03:25:30 +0000
748@@ -23,7 +23,7 @@
749
750 import unittest
751
752-from friends.protocols.linkedin import LinkedIn
753+from friends.protocols.linkedin import LinkedIn, make_fullname
754 from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
755 from friends.tests.mocks import TestModel, mock
756 from friends.errors import AuthorizationError
757@@ -46,6 +46,19 @@
758 # as to isolate out test logger from other tests.
759 self.log_mock.stop()
760
761+ def test_name_logic(self):
762+ self.assertEqual('', make_fullname())
763+ self.assertEqual('', make_fullname(irrelevant_key='foo'))
764+ self.assertEqual('Bob', make_fullname(**dict(firstName='Bob')))
765+ self.assertEqual('LastOnly', make_fullname(**dict(lastName='LastOnly')))
766+ self.assertEqual(
767+ 'Bob Loblaw',
768+ make_fullname(**dict(firstName='Bob', lastName='Loblaw')))
769+ self.assertEqual(
770+ 'Bob Loblaw',
771+ make_fullname(**dict(firstName='Bob', lastName='Loblaw',
772+ extra='ignored')))
773+
774 @mock.patch('friends.utils.authentication.manager')
775 @mock.patch('friends.utils.authentication.Accounts')
776 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
777@@ -97,14 +110,6 @@
778 '&authToken=-LNy&trk=api*a26127*s26893*',
779 1, False, '', '', '', '', '', '', '', 0.0, 0.0])
780
781- @mock.patch('friends.utils.base.Base._create_contact')
782- def test_create_contact(self, base_mock):
783- self.protocol._create_contact(
784- dict(id='jb89', firstName='Joe', lastName='Blow'))
785- base_mock.assert_called_once_with(
786- 'Joe Blow', None,
787- {'X-URIS': '', 'linkedin-id': 'jb89', 'linkedin-name': 'Joe Blow'})
788-
789 @mock.patch('friends.utils.http.Soup.Message',
790 FakeSoupMessage('friends.tests.data', 'linkedin_contacts.json'))
791 @mock.patch('friends.protocols.linkedin.LinkedIn._login',
792@@ -118,65 +123,30 @@
793 self.assertEqual(
794 push.mock_calls,
795 [mock.call(
796- {'siteStandardProfileRequest':
797- {'url': 'https://www.linkedin.com'},
798- 'pictureUrl': 'http://m.c.lnkd.licdn.com',
799- 'apiStandardProfileRequest':
800- {'url': 'http://api.linkedin.com',
801- 'headers': {'_total': 1, 'values':
802- [{'value': 'name:', 'name': 'x-li-auth-token'}]}},
803- 'industry': 'Computer Network Security',
804- 'lastName': 'A',
805- 'firstName': 'H',
806- 'headline': 'Unix Administrator at NVIDIA',
807- 'location': {'name': 'Pune Area, India',
808- 'country': {'code': 'in'}},
809- 'id': 'IFDI'}),
810-
811- mock.call(
812- {'siteStandardProfileRequest':
813- {'url': 'https://www.linkedin.com'},
814- 'pictureUrl': 'http://m.c.lnkd.licdn.com',
815- 'apiStandardProfileRequest':
816- {'url': 'http://api.linkedin.com',
817- 'headers': {'_total': 1, 'values':
818- [{'value': 'name:', 'name': 'x-li-auth-token'}]}},
819- 'industry': 'Food Production',
820- 'lastName': 'A',
821- 'firstName': 'C',
822- 'headline': 'Recent Graduate, Simon Fraser University',
823- 'location': {'name': 'Vancouver, Canada Area',
824- 'country': {'code': 'ca'}},
825- 'id': 'AefF'}),
826-
827- mock.call(
828- {'siteStandardProfileRequest':
829- {'url': 'https://www.linkedin.com'},
830- 'pictureUrl': 'http://m.c.lnkd.licdn.com',
831- 'apiStandardProfileRequest':
832- {'url': 'http://api.linkedin.com',
833- 'headers': {'_total': 1, 'values':
834- [{'value': 'name:', 'name': 'x-li-auth-token'}]}},
835- 'industry': 'Computer Software',
836- 'lastName': 'A',
837- 'firstName': 'R',
838- 'headline': 'Technical Lead at Canonical Ltd.',
839- 'location': {'name': 'Auckland, New Zealand',
840- 'country': {'code': 'nz'}},
841- 'id': 'DFdV'}),
842-
843- mock.call(
844- {'siteStandardProfileRequest':
845- {'url': 'https://www.linkedin.com'},
846- 'pictureUrl': 'http://m.c.lnkd.licdn.com',
847- 'apiStandardProfileRequest':
848- {'url': 'http://api.linkedin.com',
849- 'headers': {'_total': 1, 'values':
850- [{'value': 'name:', 'name': 'x-li-auth-token'}]}},
851- 'industry': 'Photography',
852- 'lastName': 'Z',
853- 'firstName': 'A',
854- 'headline': 'Sales manager at McBain Camera',
855- 'location': {'name': 'Edmonton, Canada Area',
856- 'country': {'code': 'ca'}},
857- 'id': 'xkBU'})])
858+ {'X-URIS': 'https://www.linkedin.com',
859+ 'X-FOLKS-WEB-SERVICES-IDS': {
860+ 'linkedin-id': 'IFDI',
861+ 'remote-full-name': 'H A'},
862+ 'linkedin-name': 'H A',
863+ 'linkedin-id': 'IFDI'}),
864+ mock.call(
865+ {'X-URIS': 'https://www.linkedin.com',
866+ 'X-FOLKS-WEB-SERVICES-IDS': {
867+ 'linkedin-id': 'AefF',
868+ 'remote-full-name': 'C A'},
869+ 'linkedin-name': 'C A',
870+ 'linkedin-id': 'AefF'}),
871+ mock.call(
872+ {'X-URIS': 'https://www.linkedin.com',
873+ 'X-FOLKS-WEB-SERVICES-IDS': {
874+ 'linkedin-id': 'DFdV',
875+ 'remote-full-name': 'R A'},
876+ 'linkedin-name': 'R A',
877+ 'linkedin-id': 'DFdV'}),
878+ mock.call(
879+ {'X-URIS': 'https://www.linkedin.com',
880+ 'X-FOLKS-WEB-SERVICES-IDS': {
881+ 'linkedin-id': 'xkBU',
882+ 'remote-full-name': 'A Z'},
883+ 'linkedin-name': 'A Z',
884+ 'linkedin-id': 'xkBU'})])
885
886=== modified file 'friends/tests/test_protocols.py'
887--- friends/tests/test_protocols.py 2013-06-21 20:17:07 +0000
888+++ friends/tests/test_protocols.py 2013-07-20 03:25:30 +0000
889@@ -440,7 +440,8 @@
890 # with-statement in Base._login().
891
892 def test_features(self):
893- self.assertEqual(MyProtocol.get_features(), ['feature_1', 'feature_2'])
894+ self.assertEqual(MyProtocol.get_features(),
895+ ['delete_contacts', 'feature_1', 'feature_2'])
896
897 def test_linkify_string(self):
898 # String with no URL is unchanged.
899
900=== modified file 'friends/tests/test_twitter.py'
901--- friends/tests/test_twitter.py 2013-06-18 22:00:44 +0000
902+++ friends/tests/test_twitter.py 2013-07-20 03:25:30 +0000
903@@ -552,56 +552,6 @@
904 get_url.assert_called_with(
905 'https://api.twitter.com/1.1/search/tweets.json?q=hello')
906
907- def test_getfriendsids(self):
908- get_url = self.protocol._get_url = mock.Mock(
909- return_value={"ids":[1,2,3]})
910- ids = self.protocol._getfriendsids()
911-
912- get_url.assert_called_with(
913- 'https://api.twitter.com/1.1/friends/ids.json'
914- )
915- self.assertEqual(ids, [1,2,3])
916-
917- def test_showuser(self):
918- get_url = self.protocol._get_url = mock.Mock(
919- return_value={"name":"Alice"})
920- userdata = self.protocol._showuser(1)
921-
922- get_url.assert_called_with(
923- 'https://api.twitter.com/1.1/users/show.json?user_id=1'
924- )
925- self.assertEqual(userdata, {"name":"Alice"})
926-
927- def test_create_contact(self, *mocks):
928- # Receive the users friends.
929- bare_contact = {'name': 'Alice Bob',
930- 'screen_name': 'alice_bob',
931- 'id': 13579}
932-
933- eds_contact = self.protocol._create_contact(bare_contact)
934- twitter_id_attr = eds_contact.get_attribute('twitter-id')
935- self.assertEqual(twitter_id_attr.get_value(), '13579')
936- twitter_name_attr = eds_contact.get_attribute('twitter-name')
937- self.assertEqual(twitter_name_attr.get_value(), 'Alice Bob')
938- web_service_addrs = eds_contact.get_attribute('X-FOLKS-WEB-SERVICES-IDS')
939- params= web_service_addrs.get_params()
940- self.assertEqual(len(params), 2)
941-
942- test_remote_name = False
943- test_twitter_id = False
944-
945- for p in params:
946- if p.get_name() == 'remote-full-name':
947- self.assertEqual(len(p.get_values()), 1)
948- self.assertEqual(p.get_values()[0], 'Alice Bob')
949- test_remote_name = True
950- if p.get_name() == 'twitter-id':
951- self.assertEqual(len(p.get_values()), 1)
952- self.assertEqual(p.get_values()[0], '13579')
953- test_twitter_id = True
954-
955- self.assertTrue(test_remote_name and test_twitter_id)
956-
957 @mock.patch('friends.protocols.twitter.time.sleep')
958 def test_rate_limiter_first_time(self, sleep):
959 # The first time we see a URL, there is no rate limiting.
960@@ -747,3 +697,34 @@
961 # sleep 100 seconds between each call.
962 self.protocol.home()
963 sleep.assert_called_with(100.0)
964+
965+ def test_contacts(self):
966+ get = self.protocol._get_url = mock.Mock(
967+ return_value=dict(ids=[1,2],name='Bob',screen_name='bobby'))
968+ prev = self.protocol._previously_stored_contact = mock.Mock(return_value=False)
969+ push = self.protocol._push_to_eds = mock.Mock()
970+ self.assertEqual(self.protocol.contacts(), 2)
971+ self.assertEqual(
972+ get.call_args_list,
973+ [mock.call('https://api.twitter.com/1.1/friends/ids.json'),
974+ mock.call(url='https://api.twitter.com/1.1/users/show.json?user_id=1'),
975+ mock.call(url='https://api.twitter.com/1.1/users/show.json?user_id=2')])
976+ self.assertEqual(
977+ prev.call_args_list,
978+ [mock.call('1'), mock.call('2')])
979+ self.assertEqual(
980+ push.call_args_list,
981+ [mock.call({'twitter-id': '1',
982+ 'X-FOLKS-WEB-SERVICES-IDS': {
983+ 'twitter-id': '1',
984+ 'remote-full-name': 'Bob'},
985+ 'X-URIS': 'https://twitter.com/bobby',
986+ 'twitter-name': 'Bob',
987+ 'twitter-nick': 'bobby'}),
988+ mock.call({'twitter-id': '2',
989+ 'X-FOLKS-WEB-SERVICES-IDS': {
990+ 'twitter-id': '2',
991+ 'remote-full-name': 'Bob'},
992+ 'X-URIS': 'https://twitter.com/bobby',
993+ 'twitter-name': 'Bob',
994+ 'twitter-nick': 'bobby'})])
995
996=== modified file 'friends/utils/base.py'
997--- friends/utils/base.py 2013-07-17 22:15:56 +0000
998+++ friends/utils/base.py 2013-07-20 03:25:30 +0000
999@@ -33,7 +33,7 @@
1000
1001 from gi.repository import GLib, GObject, EDataServer, EBook, EBookContacts
1002
1003-from friends.errors import FriendsError, ContactsError
1004+from friends.errors import FriendsError, ContactsError, ignored
1005 from friends.utils.authentication import Authentication
1006 from friends.utils.model import Schema, Model, persist_model
1007 from friends.utils.notify import notify
1008@@ -186,8 +186,11 @@
1009 The code in this class has been tested against Facebook, Twitter,
1010 Flickr, Identica, and Foursquare, and works well with all of them.
1011 """
1012- # Used for EDS stuff.
1013- _source_registry = None
1014+ # Lazily populated when needed for contact syncing.
1015+ _book_client = None
1016+ _address_book_name = None
1017+ _eds_source_registry = None
1018+ _eds_source = None
1019
1020 # This number serves a guideline (not a hard limit) for the protocol
1021 # subclasses to download in each refresh.
1022@@ -201,7 +204,6 @@
1023 self._account = account
1024 self._Name = self.__class__.__name__
1025 self._name = self._Name.lower()
1026- self._address_book = 'friends-{}-contacts'.format(self._name)
1027
1028 def _whoami(self, result):
1029 """Use OAuth login results to identify the authenticating user.
1030@@ -540,102 +542,83 @@
1031 Model.get_row(row_id)[col_idx] -= 1
1032 persist_model()
1033
1034- def _new_book_client(self, source):
1035- client = EBook.BookClient.new(source)
1036- client.open_sync(False, None)
1037- return client
1038+ def _prepare_eds_connections(self, allow_creation=True):
1039+ """Lazily establish a connection to EDS."""
1040+ if None not in (self._address_book_name, self._eds_source_registry,
1041+ self._eds_source, self._book_client):
1042+ return
1043+
1044+ self._address_book_name = 'friends-{}-contacts'.format(self._name)
1045+ self._eds_source_registry = EDataServer.SourceRegistry.new_sync(None)
1046+
1047+ self._eds_source = self._eds_source_registry.ref_source(
1048+ self._address_book_name)
1049+
1050+ # First run? Might need to create the EDS source!
1051+ if allow_creation and self._eds_source is None:
1052+ self._eds_source = EDataServer.Source.new_with_uid(
1053+ self._address_book_name, None)
1054+ self._eds_source.set_display_name(self._address_book_name)
1055+ self._eds_source.set_parent('local-stub')
1056+ self._eds_source.get_extension(
1057+ EDataServer.SOURCE_EXTENSION_ADDRESS_BOOK
1058+ ).set_backend_name('local')
1059+ self._eds_source_registry.commit_source_sync(self._eds_source, None)
1060+
1061+ if self._eds_source is not None:
1062+ self._book_client = EBook.BookClient.connect_sync(self._eds_source, None)
1063
1064 def _push_to_eds(self, contact):
1065- source_match = self._get_eds_source()
1066- if source_match is None:
1067+ self._prepare_eds_connections()
1068+ contact = self._create_contact(contact)
1069+ if not self._book_client.add_contact_sync(contact, None):
1070+ raise ContactsError('Failed to save contact {!r}'.format(contact))
1071+
1072+ def _previously_stored_contact(self, search_term):
1073+ self._prepare_eds_connections()
1074+ query = EBookContacts.BookQuery.vcard_field_test(
1075+ '{}-id'.format(self._name), EBookContacts.BookQueryTest.IS, search_term)
1076+ success, result = self._book_client.get_contacts_sync(query.to_string(), None)
1077+ if not success:
1078 raise ContactsError(
1079- '{} does not have an address book.'.format(
1080- self._address_book))
1081- client = self._new_book_client(source_match)
1082- success = client.add_contact_sync(contact, None)
1083- if not success:
1084- raise ContactsError('Failed to save contact {!r}', contact)
1085-
1086- def _get_eds_source_registry(self):
1087- if self._source_registry is None:
1088- self._source_registry = EDataServer.SourceRegistry.new_sync(None)
1089-
1090- def _create_eds_source(self):
1091- self._get_eds_source_registry()
1092- source = EDataServer.Source.new(None, None)
1093- source.set_display_name(self._address_book)
1094- source.set_parent('local-stub')
1095- extension = source.get_extension(
1096- EDataServer.SOURCE_EXTENSION_ADDRESS_BOOK)
1097- extension.set_backend_name('local')
1098- if self._source_registry.commit_source_sync(source, None):
1099- # https://bugzilla.gnome.org/show_bug.cgi?id=685986
1100- # Potential race condition - need to sleep for a
1101- # couple of cycles to ensure the registry will return
1102- # a valid source object after commiting. Evolution fix
1103- # on the way but for now we need this.
1104- time.sleep(2)
1105- return self._source_registry.ref_source(source.get_uid())
1106-
1107- def _get_eds_source(self):
1108- self._get_eds_source_registry()
1109- for previous_source in self._source_registry.list_sources(None):
1110- if previous_source.get_display_name() == self._address_book:
1111- return self._source_registry.ref_source(
1112- previous_source.get_uid())
1113- # if we got this far, then the source doesn't exist; create it
1114- return self._create_eds_source()
1115-
1116- def _previously_stored_contact(self, source, field, search_term):
1117- client = self._new_book_client(source)
1118- query = EBookContacts.BookQuery.vcard_field_test(
1119- field, EBookContacts.BookQueryTest.IS, search_term)
1120- success, result = client.get_contacts_sync(query.to_string(), None)
1121- if not success:
1122- raise ContactsError('Search failed on field {}'.format(field))
1123+ 'Id field is missing in {} address book.'.format(self._Name))
1124 return len(result) > 0
1125
1126- def _delete_service_contacts(self, source):
1127- client = self._new_book_client(source)
1128- query = EBook.book_query_any_field_contains('')
1129- success, results = client.get_contacts_sync(query.to_string(), None)
1130- if not success:
1131- raise ContactsError('Search for delete all contacts failed')
1132- log.debug('Found {} contacts to delete'.format(len(results)))
1133- for contact in results:
1134- log.debug(
1135- 'Deleting contact {}'.format(
1136- contact.get_property('full-name')))
1137- client.remove_contact_sync(contact, None)
1138- return True
1139-
1140- def _create_contact(self, user_fullname, user_nickname,
1141- social_network_attrs):
1142+ def _create_contact(self, info):
1143 """Build a VCard based on a dict representation of a contact."""
1144- vcard = EBookContacts.VCard.new()
1145- info = social_network_attrs
1146+ contact = EBookContacts.Contact.new()
1147
1148- for key, val in info.items():
1149+ for key, value in info.items():
1150 attr = EBookContacts.VCardAttribute.new(
1151 'social-networking-attributes', key)
1152- if isinstance(val, dict):
1153- for subkey, subval in val.items():
1154- param = EBookContacts.VCardAttributeParam.new(subkey)
1155- param.add_value(subval)
1156- attr.add_param(param);
1157+ if hasattr(value, 'items'):
1158+ for subkey, subval in value.items():
1159+ if subval is not None:
1160+ param = EBookContacts.VCardAttributeParam.new(subkey)
1161+ param.add_value(subval)
1162+ attr.add_param(param);
1163+ elif value is not None:
1164+ attr.add_value(value)
1165 else:
1166- attr.add_value(val)
1167- vcard.add_attribute(attr)
1168-
1169- contact = EBookContacts.Contact.new_from_vcard(
1170- vcard.to_string(EBookContacts.VCardFormat(1)))
1171- contact.set_property('full-name', user_fullname)
1172- if user_nickname is not None:
1173- contact.set_property('nickname', user_nickname)
1174-
1175- log.debug('Creating new contact for {}'.format(user_fullname))
1176+ continue
1177+ contact.add_attribute(attr)
1178+
1179+ properties = (('full-name', '{}-name'),
1180+ ('nickname', '{}-nick'))
1181+ for prop, key in properties:
1182+ value = info.get(key.format(self._name))
1183+ if value is not None:
1184+ contact.set_property(prop, value)
1185+ log.debug('New contact got {}: {}'.format(prop, value))
1186 return contact
1187
1188+ @feature
1189+ def delete_contacts(self):
1190+ """Remove all synced contacts from this social network."""
1191+ self._prepare_eds_connections(allow_creation=False)
1192+ with ignored(GLib.GError, AttributeError):
1193+ return self._eds_source.remove_sync(None)
1194+
1195 @classmethod
1196 def get_features(cls):
1197 """Report what public operations we expose over DBus."""

Subscribers

People subscribed via source and target branches