Merge lp:~super-friends/friends/raring into lp:friends

Proposed by Ken VanDine on 2013-03-20
Status: Merged
Approved by: Ken VanDine on 2013-03-20
Approved revision: 182
Merged at revision: 164
Proposed branch: lp:~super-friends/friends/raring
Merge into: lp:friends
Diff against target: 3182 lines (+1267/-632)
34 files modified
debian/changelog (+18/-0)
debian/friends-dispatcher.install (+3/-0)
debian/rules (+4/-1)
friends/main.py (+9/-8)
friends/protocols/facebook.py (+47/-20)
friends/protocols/flickr.py (+5/-2)
friends/protocols/foursquare.py (+6/-1)
friends/protocols/twitter.py (+7/-3)
friends/service/dispatcher.py (+58/-8)
friends/tests/data/facebook-full.dat (+366/-1)
friends/tests/data/flickr-full.dat (+13/-1)
friends/tests/mocks.py (+50/-2)
friends/tests/test_account.py (+3/-62)
friends/tests/test_cache.py (+0/-4)
friends/tests/test_dispatcher.py (+75/-10)
friends/tests/test_facebook.py (+153/-72)
friends/tests/test_flickr.py (+59/-76)
friends/tests/test_foursquare.py (+7/-15)
friends/tests/test_identica.py (+2/-10)
friends/tests/test_mock_dispatcher.py (+0/-2)
friends/tests/test_model.py (+11/-2)
friends/tests/test_notify.py (+1/-14)
friends/tests/test_protocols.py (+154/-79)
friends/tests/test_shortener.py (+0/-2)
friends/tests/test_twitter.py (+52/-32)
friends/utils/account.py (+3/-23)
friends/utils/authentication.py (+6/-1)
friends/utils/base.py (+97/-143)
friends/utils/model.py (+9/-20)
service/configure.ac (+1/-0)
service/src/Makefile.am (+3/-2)
service/src/service.vala (+43/-15)
setup.py (+1/-0)
tools/debug_live.py (+1/-1)
To merge this branch: bzr merge lp:~super-friends/friends/raring
Reviewer Review Type Date Requested Status
PS Jenkins bot (community) continuous-integration Approve on 2013-03-20
Ken VanDine Approve on 2013-03-20
Review via email: mp+154365@code.launchpad.net

Commit message

* Stop deduplicating messages across protocols, simplifying model
    schema (LP: #1156941)
  * Add schema columns for latitude, longitude, and location name.
  * Fix 'likes' column from gdouble to guint64.
  * Add geotagging support from foursquare, facebook, flickr.
  * Implement since= for Facebook, reducing bandwidth usage.
  * Automatically prepend the required @mention to Twitter
    replies (LP: #1156829)
  * Automatically linkify URLs that get published to the model.
  * Fix the publishing of Facebook Stories (LP: #1155785)

Description of the change

Merge queued up changes for raring to trunk now that the FFe bug 1156979 was approved

To post a comment you must log in.
Ken VanDine (ken-vandine) wrote :

All the changes have been reviewed before merging into the raring branch.

review: Approve
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'debian/changelog'
--- debian/changelog 2013-03-19 05:03:07 +0000
+++ debian/changelog 2013-03-20 13:27:53 +0000
@@ -1,3 +1,21 @@
1friends (0.1.3-0ubuntu1) UNRELEASED; urgency=low
2
3 [ Robert Bruce Park ]
4 * Keep the Dispatcher alive for 30s beyond the return of the final
5 method invocation.
6 * Stop deduplicating messages across protocols, simplifying model
7 schema (LP: #1156941)
8 * Add schema columns for latitude, longitude, and location name.
9 * Fix 'likes' column from gdouble to guint64.
10 * Add geotagging support from foursquare, facebook, flickr.
11 * Implement since= for Facebook, reducing bandwidth usage.
12 * Automatically prepend the required @mention to Twitter
13 replies (LP: #1156829)
14 * Automatically linkify URLs that get published to the model.
15 * Fix the publishing of Facebook Stories (LP: #1155785)
16
17 -- Ken VanDine <ken.vandine@canonical.com> Wed, 20 Mar 2013 09:14:15 -0400
18
1friends (0.1.2daily13.03.19-0ubuntu1) raring; urgency=low19friends (0.1.2daily13.03.19-0ubuntu1) raring; urgency=low
220
3 [ Martin Pitt ]21 [ Martin Pitt ]
422
=== modified file 'debian/friends-dispatcher.install'
--- debian/friends-dispatcher.install 2013-02-05 01:25:43 +0000
+++ debian/friends-dispatcher.install 2013-03-20 13:27:53 +0000
@@ -5,4 +5,7 @@
5usr/lib/python3/dist-packages/friends/service/*5usr/lib/python3/dist-packages/friends/service/*
6usr/lib/python3/dist-packages/friends/shorteners/*6usr/lib/python3/dist-packages/friends/shorteners/*
7usr/lib/python3/dist-packages/friends/utils/*7usr/lib/python3/dist-packages/friends/utils/*
8usr/lib/python3/dist-packages/friends/tests/mocks.py
9usr/lib/python3/dist-packages/friends/tests/__init__.py
10usr/lib/python3/dist-packages/friends/tests/data/*
8usr/share/dbus-1/services/com.canonical.Friends.Dispatcher.service11usr/share/dbus-1/services/com.canonical.Friends.Dispatcher.service
912
=== modified file 'debian/rules'
--- debian/rules 2013-02-13 05:43:57 +0000
+++ debian/rules 2013-03-20 13:27:53 +0000
@@ -18,8 +18,11 @@
18override_dh_auto_install:18override_dh_auto_install:
19 python3 setup.py install --root=$(CURDIR)/debian/tmp --install-layout=deb19 python3 setup.py install --root=$(CURDIR)/debian/tmp --install-layout=deb
20 python3 setup.py install_service_files -d $(CURDIR)/debian/tmp/usr20 python3 setup.py install_service_files -d $(CURDIR)/debian/tmp/usr
21 rm -rf $(CURDIR)/debian/tmp/usr/lib/python3/dist-packages/friends/tests/test*
22 rm -rf $(CURDIR)/debian/tmp/usr/lib/python3/dist-packages/friends/*/__pycache__
23 rm -rf $(CURDIR)/debian/tmp/usr/lib/python3/dist-packages/friends/__pycache__
21 dh_auto_install -D service24 dh_auto_install -D service
22 dh_install --list-missing25 dh_install --fail-missing
2326
24override_dh_auto_test:27override_dh_auto_test:
25 dbus-test-runner -t make -p check -m 30028 dbus-test-runner -t make -p check -m 300
2629
=== modified file 'friends/main.py'
--- friends/main.py 2013-03-01 20:28:27 +0000
+++ friends/main.py 2013-03-20 13:27:53 +0000
@@ -43,11 +43,16 @@
4343
44if args.test:44if args.test:
45 from friends.service.mock_service import Dispatcher45 from friends.service.mock_service import Dispatcher
46 from friends.tests.mocks import populate_fake_data
47
48 populate_fake_data()
46 Dispatcher()49 Dispatcher()
50
47 try:51 try:
48 loop.run()52 loop.run()
49 except KeyboardInterrupt:53 except KeyboardInterrupt:
50 pass54 pass
55
51 sys.exit(0)56 sys.exit(0)
5257
5358
@@ -57,11 +62,9 @@
57GObject.threads_init(None)62GObject.threads_init(None)
5863
59from friends.service.dispatcher import Dispatcher, DBUS_INTERFACE64from friends.service.dispatcher import Dispatcher, DBUS_INTERFACE
60from friends.utils.base import _OperationThread, _publish_lock65from friends.utils.base import Base, initialize_caches, _publish_lock
61from friends.utils.base import Base, initialize_caches
62from friends.utils.model import Model, prune_model66from friends.utils.model import Model, prune_model
63from friends.utils.logging import initialize67from friends.utils.logging import initialize
64from friends.utils.avatar import Avatar
6568
6669
67# Optional performance profiling module.70# Optional performance profiling module.
@@ -84,10 +87,6 @@
84 print(class_name)87 print(class_name)
85 return88 return
8689
87 # Our threading implementation needs to know how to quit the
88 # application once all threads have completed.
89 _OperationThread.shutdown = loop.quit
90
91 # Disallow multiple instances of friends-dispatcher90 # Disallow multiple instances of friends-dispatcher
92 bus = dbus.SessionBus()91 bus = dbus.SessionBus()
93 obj = bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus')92 obj = bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus')
@@ -140,7 +139,9 @@
140 log.info('Starting friends-dispatcher main loop')139 log.info('Starting friends-dispatcher main loop')
141 loop.run()140 loop.run()
142 except KeyboardInterrupt:141 except KeyboardInterrupt:
143 log.info('Stopped friends-dispatcher main loop')142 pass
143
144 log.info('Stopped friends-dispatcher main loop')
144145
145 # This bit doesn't run until after the mainloop exits.146 # This bit doesn't run until after the mainloop exits.
146 if args.performance and yappi is not None:147 if args.performance and yappi is not None:
147148
=== modified file 'friends/protocols/facebook.py'
--- friends/protocols/facebook.py 2013-02-27 22:22:38 +0000
+++ friends/protocols/facebook.py 2013-03-20 13:27:53 +0000
@@ -24,10 +24,9 @@
24import time24import time
25import logging25import logging
2626
27from datetime import datetime, timedelta
28
29from friends.utils.avatar import Avatar27from friends.utils.avatar import Avatar
30from friends.utils.base import Base, feature28from friends.utils.base import Base, feature
29from friends.utils.cache import JsonCache
31from friends.utils.http import Downloader, Uploader30from friends.utils.http import Downloader, Uploader
32from friends.utils.time import parsetime, iso8601utc31from friends.utils.time import parsetime, iso8601utc
33from friends.errors import FriendsError32from friends.errors import FriendsError
@@ -42,10 +41,17 @@
42FACEBOOK_ADDRESS_BOOK = 'friends-facebook-contacts'41FACEBOOK_ADDRESS_BOOK = 'friends-facebook-contacts'
4342
4443
44TEN_DAYS = 864000 # seconds
45
46
45log = logging.getLogger(__name__)47log = logging.getLogger(__name__)
4648
4749
48class Facebook(Base):50class Facebook(Base):
51 def __init__(self, account):
52 super().__init__(account)
53 self._timestamps = PostIdCache(self._name + '_ids')
54
49 def _whoami(self, authdata):55 def _whoami(self, authdata):
50 """Identify the authenticating user."""56 """Identify the authenticating user."""
51 me_data = Downloader(57 me_data = Downloader(
@@ -59,15 +65,22 @@
59 # We can't do much with this entry.65 # We can't do much with this entry.
60 return66 return
6167
68 place = entry.get('place', {})
69 location = place.get('location', {})
70
62 args = dict(71 args = dict(
72 message_id=message_id,
63 stream=stream,73 stream=stream,
64 message=entry.get('message', ''),74 message=entry.get('message', '') or entry.get('story', ''),
65 icon_uri=entry.get('icon', ''),75 icon_uri=entry.get('icon', ''),
66 link_picture=entry.get('picture', ''),76 link_picture=entry.get('picture', ''),
67 link_name=entry.get('name', ''),77 link_name=entry.get('name', ''),
68 link_url=entry.get('link', ''),78 link_url=entry.get('link', ''),
69 link_desc=entry.get('description', ''),79 link_desc=entry.get('description', ''),
70 link_caption=entry.get('caption', ''),80 link_caption=entry.get('caption', ''),
81 location=place.get('name', ''),
82 latitude=location.get('latitude', 0.0),
83 longitude=location.get('longitude', 0.0),
71 )84 )
7285
73 # Posts gives us a likes dict, while replies give us an int.86 # Posts gives us a likes dict, while replies give us an int.
@@ -89,10 +102,16 @@
89 # Normalize the timestamp.102 # Normalize the timestamp.
90 timestamp = entry.get('updated_time', entry.get('created_time'))103 timestamp = entry.get('updated_time', entry.get('created_time'))
91 if timestamp is not None:104 if timestamp is not None:
92 args['timestamp'] = iso8601utc(parsetime(timestamp))105 timestamp = args['timestamp'] = iso8601utc(parsetime(timestamp))
106 # We need to record timestamps for use with since=. Note that
107 # _timestamps is a special dict subclass that only accepts
108 # timestamps that are larger than the existing value, so at any
109 # given time it will map the stream to the most
110 # recent timestamp we've seen for that stream.
111 self._timestamps[stream] = timestamp
93112
94 # Publish this message into the SharedModel.113 # Publish this message into the SharedModel.
95 self._publish(message_id, **args)114 self._publish(**args)
96115
97 # If there are any replies, publish them as well.116 # If there are any replies, publish them as well.
98 for comment in entry.get('comments', {}).get('data', []):117 for comment in entry.get('comments', {}).get('data', []):
@@ -109,6 +128,7 @@
109128
110 while True:129 while True:
111 response = Downloader(url, params).get_json()130 response = Downloader(url, params).get_json()
131
112 if self._is_error(response):132 if self._is_error(response):
113 break133 break
114134
@@ -137,24 +157,18 @@
137 # We've gotten everything Facebook is going to give us.157 # We've gotten everything Facebook is going to give us.
138 return entries158 return entries
139159
140 def _get(self, url, stream, since=None):160 def _get(self, url, stream):
141 """Retrieve a list of Facebook objects.161 """Retrieve a list of Facebook objects.
142162
143 A maximum of 50 objects are requested.163 A maximum of 50 objects are requested.
144
145 :param since: Only get objects posted since this date. If not given,
146 then only objects younger than 10 days are retrieved. The value
147 is a number seconds since the epoch.
148 :type since: float
149 """164 """
150 access_token = self._get_access_token()165 access_token = self._get_access_token()
151 if since is None:166 since = self._timestamps.get(
152 when = datetime.now() - timedelta(days=10)167 stream, iso8601utc(int(time.time()) - TEN_DAYS))
153 else:168
154 when = datetime.fromtimestamp(since)
155 entries = []169 entries = []
156 params = dict(access_token=access_token,170 params = dict(access_token=access_token,
157 since=when.isoformat(),171 since=since,
158 limit=self._DOWNLOAD_LIMIT)172 limit=self._DOWNLOAD_LIMIT)
159173
160 entries = self._follow_pagination(url, params)174 entries = self._follow_pagination(url, params)
@@ -163,15 +177,15 @@
163 self._publish_entry(entry, stream=stream)177 self._publish_entry(entry, stream=stream)
164178
165 @feature179 @feature
166 def home(self, since=None):180 def home(self):
167 """Gather and publish public timeline messages."""181 """Gather and publish public timeline messages."""
168 self._get(ME_URL + '/home', 'messages', since)182 self._get(ME_URL + '/home', 'messages')
169 return self._get_n_rows()183 return self._get_n_rows()
170184
171 @feature185 @feature
172 def wall(self, since=None):186 def wall(self):
173 """Gather and publish messages written on user's wall."""187 """Gather and publish messages written on user's wall."""
174 self._get(ME_URL + '/feed', 'mentions', since)188 self._get(ME_URL + '/feed', 'mentions')
175 return self._get_n_rows()189 return self._get_n_rows()
176190
177 @feature191 @feature
@@ -369,3 +383,16 @@
369 def delete_contacts(self):383 def delete_contacts(self):
370 source = self._get_eds_source(FACEBOOK_ADDRESS_BOOK)384 source = self._get_eds_source(FACEBOOK_ADDRESS_BOOK)
371 return self._delete_service_contacts(source)385 return self._delete_service_contacts(source)
386
387
388class PostIdCache(JsonCache):
389 """Persist most-recent timestamps as JSON."""
390
391 def __setitem__(self, key, value):
392 if key.find('/') >= 0:
393 # Don't flood the cache with irrelevant "reply_to/..." and
394 # "search/..." streams, we only need the main streams.
395 return
396 # Thank SCIENCE for lexically-sortable timestamp strings!
397 if value > self.get(key, ''):
398 JsonCache.__setitem__(self, key, value)
372399
=== modified file 'friends/protocols/flickr.py'
--- friends/protocols/flickr.py 2013-02-27 23:04:36 +0000
+++ friends/protocols/flickr.py 2013-03-20 13:27:53 +0000
@@ -113,7 +113,7 @@
113 method='flickr.photos.getContactsPhotos',113 method='flickr.photos.getContactsPhotos',
114 format='json',114 format='json',
115 nojsoncallback='1',115 nojsoncallback='1',
116 extras='date_upload,owner_name,icon_server',116 extras='date_upload,owner_name,icon_server,geo',
117 )117 )
118118
119 response = self._get_url(args)119 response = self._get_url(args)
@@ -166,7 +166,10 @@
166 link_caption=data.get('title', ''),166 link_caption=data.get('title', ''),
167 link_url=img_url,167 link_url=img_url,
168 link_picture=img_src,168 link_picture=img_src,
169 link_icon=img_thumb)169 link_icon=img_thumb,
170 latitude=data.get('latitude', 0.0),
171 longitude=data.get('longitude', 0.0),
172 )
170 return self._get_n_rows()173 return self._get_n_rows()
171174
172# http://www.flickr.com/services/api/upload.api.html175# http://www.flickr.com/services/api/upload.api.html
173176
=== modified file 'friends/protocols/foursquare.py'
--- friends/protocols/foursquare.py 2013-02-26 19:05:10 +0000
+++ friends/protocols/foursquare.py 2013-03-20 13:27:53 +0000
@@ -85,6 +85,8 @@
85 checkin_id = checkin.get('id', '')85 checkin_id = checkin.get('id', '')
86 tz_offset = checkin.get('timeZoneOffset', 0)86 tz_offset = checkin.get('timeZoneOffset', 0)
87 epoch = checkin.get('createdAt', 0)87 epoch = checkin.get('createdAt', 0)
88 venue = checkin.get('venue', {})
89 location = venue.get('location', {})
88 self._publish(90 self._publish(
89 message_id=checkin_id,91 message_id=checkin_id,
90 stream='messages',92 stream='messages',
@@ -94,6 +96,9 @@
94 message=checkin.get('shout', ''),96 message=checkin.get('shout', ''),
95 likes=checkin.get('likes', {}).get('count', 0),97 likes=checkin.get('likes', {}).get('count', 0),
96 icon_uri=Avatar.get_image(avatar_url),98 icon_uri=Avatar.get_image(avatar_url),
97 url=checkin.get('venue', {}).get('canonicalUrl', ''),99 url=venue.get('canonicalUrl', ''),
100 location=venue.get('name', ''),
101 latitude=location.get('lat', 0.0),
102 longitude=location.get('lng', 0.0),
98 )103 )
99 return self._get_n_rows()104 return self._get_n_rows()
100105
=== modified file 'friends/protocols/twitter.py'
--- friends/protocols/twitter.py 2013-03-08 02:31:15 +0000
+++ friends/protocols/twitter.py 2013-03-20 13:27:53 +0000
@@ -26,12 +26,10 @@
26import logging26import logging
2727
28from urllib.parse import quote28from urllib.parse import quote
29from gi.repository import GLib
3029
31from friends.utils.avatar import Avatar30from friends.utils.avatar import Avatar
32from friends.utils.base import Base, feature31from friends.utils.base import Base, feature
33from friends.utils.cache import JsonCache32from friends.utils.cache import JsonCache
34from friends.utils.model import Model
35from friends.utils.http import BaseRateLimiter, Downloader33from friends.utils.http import BaseRateLimiter, Downloader
36from friends.utils.time import parsetime, iso8601utc34from friends.utils.time import parsetime, iso8601utc
37from friends.errors import FriendsError35from friends.errors import FriendsError
@@ -70,7 +68,7 @@
70 super().__init__(account)68 super().__init__(account)
71 self._rate_limiter = RateLimiter()69 self._rate_limiter = RateLimiter()
72 # Can be 'twitter_ids' or 'identica_ids'70 # Can be 'twitter_ids' or 'identica_ids'
73 self._tweet_ids = TweetIdCache(self.__class__.__name__.lower() + '_ids')71 self._tweet_ids = TweetIdCache(self._name + '_ids')
7472
75 def _whoami(self, authdata):73 def _whoami(self, authdata):
76 """Identify the authenticating user."""74 """Identify the authenticating user."""
@@ -254,6 +252,12 @@
254 order for Twitter to actually accept this as a reply. Otherwise it252 order for Twitter to actually accept this as a reply. Otherwise it
255 will just be an ordinary tweet.253 will just be an ordinary tweet.
256 """254 """
255 try:
256 sender = '@{}'.format(self._fetch_cell(message_id, 'sender_nick'))
257 if message.find(sender) < 0:
258 message = sender + ' ' + message
259 except FriendsError:
260 pass
257 url = self._api_base.format(endpoint='statuses/update')261 url = self._api_base.format(endpoint='statuses/update')
258 tweet = self._get_url(url, dict(in_reply_to_status_id=message_id,262 tweet = self._get_url(url, dict(in_reply_to_status_id=message_id,
259 status=message))263 status=message))
260264
=== modified file 'friends/service/dispatcher.py'
--- friends/service/dispatcher.py 2013-02-20 13:08:27 +0000
+++ friends/service/dispatcher.py 2013-03-20 13:27:53 +0000
@@ -28,12 +28,13 @@
28import dbus.service28import dbus.service
2929
30from gi.repository import GLib30from gi.repository import GLib
31from contextlib import ContextDecorator
3132
32from friends.utils.avatar import Avatar33from friends.utils.avatar import Avatar
33from friends.utils.account import AccountManager34from friends.utils.account import AccountManager
34from friends.utils.manager import protocol_manager35from friends.utils.manager import protocol_manager
35from friends.utils.menus import MenuManager36from friends.utils.menus import MenuManager
36from friends.utils.model import Model37from friends.utils.model import Model, persist_model
37from friends.shorteners import lookup38from friends.shorteners import lookup
3839
3940
@@ -43,6 +44,48 @@
43STUB = lambda *ignore, **kwignore: None44STUB = lambda *ignore, **kwignore: None
4445
4546
47# Avoid race condition during shut-down
48_exit_lock = threading.Lock()
49
50
51class ManageTimers(ContextDecorator):
52 """Exit the dispatcher 30s after the most recent method call returns."""
53 timers = set()
54 callback = STUB
55
56 def __enter__(self):
57 self.clear_all_timers()
58
59 def __exit__(self, *ignore):
60 self.set_new_timer()
61
62 def clear_all_timers(self):
63 log.debug('Clearing {} shutdown timer(s)...'.format(len(self.timers)))
64 while self.timers:
65 GLib.source_remove(self.timers.pop())
66
67 def set_new_timer(self):
68 # Concurrency will cause two methods to exit near each other,
69 # causing two timers to be set, so we have to clear them again.
70 self.clear_all_timers()
71 log.debug('Starting new shutdown timer...')
72 self.timers.add(GLib.timeout_add_seconds(30, self.terminate))
73
74 def terminate(self, *ignore):
75 """Exit the dispatcher, but only if there are no active subthreads."""
76 with _exit_lock:
77 if threading.activeCount() < 2:
78 log.debug('No threads found, shutting down.')
79 persist_model()
80 self.timers.add(GLib.idle_add(self.callback))
81 else:
82 log.debug('Delaying shutdown because active threads found.')
83 self.set_new_timer()
84
85
86exit_after_idle = ManageTimers()
87
88
46class Dispatcher(dbus.service.Object):89class Dispatcher(dbus.service.Object):
47 """This is the primary handler of dbus method calls."""90 """This is the primary handler of dbus method calls."""
48 __dbus_object_path__ = '/com/canonical/friends/Dispatcher'91 __dbus_object_path__ = '/com/canonical/friends/Dispatcher'
@@ -59,10 +102,13 @@
59 self.menu_manager = MenuManager(self.Refresh, self.mainloop.quit)102 self.menu_manager = MenuManager(self.Refresh, self.mainloop.quit)
60 Model.connect('row-added', self._increment_unread_count)103 Model.connect('row-added', self._increment_unread_count)
61104
105 ManageTimers.callback = mainloop.quit
106
62 def _increment_unread_count(self, model, itr):107 def _increment_unread_count(self, model, itr):
63 self._unread_count += 1108 self._unread_count += 1
64 self.menu_manager.update_unread_count(self._unread_count)109 self.menu_manager.update_unread_count(self._unread_count)
65110
111 @exit_after_idle
66 @dbus.service.method(DBUS_INTERFACE)112 @dbus.service.method(DBUS_INTERFACE)
67 def Refresh(self):113 def Refresh(self):
68 """Download new messages from each connected protocol."""114 """Download new messages from each connected protocol."""
@@ -80,6 +126,7 @@
80 # If a protocol doesn't support receive then ignore it.126 # If a protocol doesn't support receive then ignore it.
81 pass127 pass
82128
129 @exit_after_idle
83 @dbus.service.method(DBUS_INTERFACE)130 @dbus.service.method(DBUS_INTERFACE)
84 def ClearIndicators(self):131 def ClearIndicators(self):
85 """Indicate that messages have been read.132 """Indicate that messages have been read.
@@ -92,8 +139,8 @@
92 service.ClearIndicators()139 service.ClearIndicators()
93 """140 """
94 self.menu_manager.update_unread_count(0)141 self.menu_manager.update_unread_count(0)
95 GLib.idle_add(self.mainloop.quit)
96142
143 @exit_after_idle
97 @dbus.service.method(DBUS_INTERFACE,144 @dbus.service.method(DBUS_INTERFACE,
98 in_signature='sss',145 in_signature='sss',
99 out_signature='s',146 out_signature='s',
@@ -117,7 +164,7 @@
117 """164 """
118 if account_id:165 if account_id:
119 accounts = [self.account_manager.get(account_id)]166 accounts = [self.account_manager.get(account_id)]
120 if accounts == [None]:167 if None in accounts:
121 message = 'Could not find account: {}'.format(account_id)168 message = 'Could not find account: {}'.format(account_id)
122 failure(message)169 failure(message)
123 log.error(message)170 log.error(message)
@@ -138,6 +185,7 @@
138 if not called:185 if not called:
139 failure('No accounts supporting {} found.'.format(action))186 failure('No accounts supporting {} found.'.format(action))
140187
188 @exit_after_idle
141 @dbus.service.method(DBUS_INTERFACE,189 @dbus.service.method(DBUS_INTERFACE,
142 in_signature='s',190 in_signature='s',
143 out_signature='s',191 out_signature='s',
@@ -162,7 +210,7 @@
162 sent = True210 sent = True
163 log.debug(211 log.debug(
164 'Sending message to {}'.format(212 'Sending message to {}'.format(
165 account.protocol.__class__.__name__))213 account.protocol._Name))
166 account.protocol(214 account.protocol(
167 'send',215 'send',
168 message,216 message,
@@ -172,6 +220,7 @@
172 if not sent:220 if not sent:
173 failure('No send_enabled accounts found.')221 failure('No send_enabled accounts found.')
174222
223 @exit_after_idle
175 @dbus.service.method(DBUS_INTERFACE,224 @dbus.service.method(DBUS_INTERFACE,
176 in_signature='sss',225 in_signature='sss',
177 out_signature='s',226 out_signature='s',
@@ -205,6 +254,7 @@
205 failure(message)254 failure(message)
206 log.error(message)255 log.error(message)
207256
257 @exit_after_idle
208 @dbus.service.method(DBUS_INTERFACE,258 @dbus.service.method(DBUS_INTERFACE,
209 in_signature='sss',259 in_signature='sss',
210 out_signature='s',260 out_signature='s',
@@ -262,6 +312,7 @@
262 failure(message)312 failure(message)
263 log.error(message)313 log.error(message)
264314
315 @exit_after_idle
265 @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s')316 @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s')
266 def GetFeatures(self, protocol_name):317 def GetFeatures(self, protocol_name):
267 """Returns a list of features supported by service as json string.318 """Returns a list of features supported by service as json string.
@@ -274,9 +325,9 @@
274 features = json.loads(service.GetFeatures('facebook'))325 features = json.loads(service.GetFeatures('facebook'))
275 """326 """
276 protocol = protocol_manager.protocols.get(protocol_name)327 protocol = protocol_manager.protocols.get(protocol_name)
277 GLib.idle_add(self.mainloop.quit)328 return json.dumps(protocol.get_features() if protocol else [])
278 return json.dumps(protocol.get_features())
279329
330 @exit_after_idle
280 @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s')331 @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s')
281 def URLShorten(self, url):332 def URLShorten(self, url):
282 """Shorten a URL.333 """Shorten a URL.
@@ -291,7 +342,6 @@
291 service = dbus.Interface(obj, DBUS_INTERFACE)342 service = dbus.Interface(obj, DBUS_INTERFACE)
292 short_url = service.URLShorten(url)343 short_url = service.URLShorten(url)
293 """344 """
294 GLib.idle_add(self.mainloop.quit)
295 service_name = self.settings.get_string('urlshorter')345 service_name = self.settings.get_string('urlshorter')
296 log.info('Shortening URL {} with {}'.format(url, service_name))346 log.info('Shortening URL {} with {}'.format(url, service_name))
297 if (lookup.is_shortened(url) or347 if (lookup.is_shortened(url) or
@@ -305,7 +355,7 @@
305 log.exception('URL shortening class: {}'.format(service))355 log.exception('URL shortening class: {}'.format(service))
306 return url356 return url
307357
358 @exit_after_idle
308 @dbus.service.method(DBUS_INTERFACE)359 @dbus.service.method(DBUS_INTERFACE)
309 def ExpireAvatars(self):360 def ExpireAvatars(self):
310 Avatar.expire_old_avatars()361 Avatar.expire_old_avatars()
311 GLib.idle_add(self.mainloop.quit)
312362
=== modified file 'friends/tests/data/facebook-full.dat'
--- friends/tests/data/facebook-full.dat 2012-10-13 01:27:15 +0000
+++ friends/tests/data/facebook-full.dat 2013-03-20 13:27:53 +0000
@@ -1,1 +1,366 @@
1{"paging": {"previous": "https://graph.facebook.com/101/home?access_token=ABC&limit=25&since=1348682101&__previous=1"}, "data": [{"picture": "https://fbexternal-a.akamaihd.net/rush.jpg", "from": {"category": "Entertainment", "name": "Rush is a Band", "id": "117402931676347"}, "name": "Rush is a Band Blog", "comments": {"count": 0}, "actions": [{"link": "https://www.facebook.com/117402931676347/posts/287578798009078", "name": "Comment"}, {"link": "https://www.facebook.com/117402931676347/posts/287578798009078", "name": "Like"}], "updated_time": "2012-09-26T17:34:00+0000", "caption": "www.rushisaband.com", "link": "http://www.rushisaband.com/blog/Rush-Clockwork-Angels-tour", "likes": {"count": 16, "data": [{"name": "Alex Lifeson", "id": "801"}, {"name": "Vlada Lee", "id": "801"}, {"name": "Richard Peart", "id": "803"}, {"name": "Eric Lifeson", "id": "804"}]}, "created_time": "2012-09-26T17:34:00+0000", "message": "Rush takes off to the Great White North", "icon": "https://s-static.ak.facebook.com/rsrc.php/v2/yD/r/a.gif", "type": "link", "id": "108", "status_type": "shared_story", "description": "Rush is a Band: Neil Peart, Geddy Lee, Alex Lifeson"}, {"picture": "https://images.gibson.com/Rush_Clockwork-Angels_t.jpg", "likes": {"count": 27, "data": [{"name": "Tracy Lee", "id": "805"}, {"name": "Wendy Peart", "id": "806"}, {"name": "Vlada Lifeson", "id": "807"}, {"name": "Chevy Lee", "id": "808"}]}, "from": {"category": "Entertainment", "name": "Rush is a Band", "id": "117402931676347"}, "name": "Top 10 Alex Lifeson Guitar Moments", "comments": {"count": 5, "data": [{"created_time": "2012-09-26T17:16:00+0000", "message": "OK Don...10) Headlong Flight", "from": {"name": "Bruce Peart", "id": "809"}, "id": "117402931676347_386054134801436_3235476"}, {"created_time": "2012-09-26T17:49:06+0000", "message": "No Cygnus X-1 Bruce? I call shenanigans!", "from": {"name": "Don Lee", "id": "810"}, "id": "117402931676347_386054134801436_3235539"}]}, "actions": [{"link": "https://www.facebook.com/117402931676347/posts/386054134801436", "name": "Comment"}, {"link": "https://www.facebook.com/117402931676347/posts/386054134801436", "name": "Like"}], "updated_time": "2012-09-26T17:49:06+0000", "caption": "www2.gibson.com", "link": "http://www2.gibson.com/Alex-Lifeson.aspx", "shares": {"count": 11}, "created_time": "2012-09-26T16:42:15+0000", "message": "http://www2.gibson.com/Alex-Lifeson-0225-2011.aspx", "icon": "https://s-static.ak.facebook.com/rsrc.php/v2/yD/r/a.gif", "type": "link", "id": "109", "status_type": "shared_story", "description": "For millions of Rush fans old and new, it\u2019s a pleasure"}]}
2\ No newline at end of file1\ No newline at end of file
2{
3 "data": [
4 {
5 "id": "fake_id",
6 "from": {
7 "name": "Yours Truly",
8 "id": "56789"
9 },
10 "message": "Writing code that supports geotagging data from facebook. If y'all could make some geotagged facebook posts for me to test with, that'd be super.",
11 "actions": [
12 {
13 "name": "Comment",
14 "link": "https://www.facebook.com/fake/posts/id"
15 },
16 {
17 "name": "Like",
18 "link": "https://www.facebook.com/fake/posts/id"
19 }
20 ],
21 "privacy": {
22 "value": ""
23 },
24 "place": {
25 "id": "103135879727382",
26 "name": "Victoria, British Columbia",
27 "location": {
28 "street": "",
29 "zip": "",
30 "latitude": 48.4333,
31 "longitude": -123.35
32 }
33 },
34 "type": "status",
35 "status_type": "mobile_status_update",
36 "created_time": "2013-03-12T21:27:56+0000",
37 "updated_time": "2013-03-13T23:29:07+0000",
38 "likes": {
39 "data": [
40 {
41 "name": "Anna",
42 "id": "12345"
43 }
44 ],
45 "count": 1
46 },
47 "comments": {
48 "data": [
49 {
50 "id": "fake as a snake",
51 "from": {
52 "name": "Grandma",
53 "id": "9876"
54 },
55 "message": "If I knew what a geotagged facebook post was I might be able to comply!",
56 "created_time": "2013-03-12T22:56:17+0000"
57 },
58 {
59 "id": "faker than cake!",
60 "from": {
61 "name": "Father",
62 "id": "234"
63 },
64 "message": "don't know how",
65 "created_time": "2013-03-12T23:29:45+0000"
66 },
67 {
68 "id": "still fake",
69 "from": {
70 "name": "Mother",
71 "id": "456"
72 },
73 "message": "HUH!!!!",
74 "created_time": "2013-03-13T02:20:27+0000"
75 },
76 {
77 "id": "this one is real",
78 "from": {
79 "name": "Yours Truly",
80 "id": "56789"
81 },
82 "message": "Coming up with tons of fake data is hard!",
83 "created_time": "2013-03-13T23:29:07+0000"
84 }
85 ],
86 "count": 4
87 }
88 },
89 {
90 "id": "270843027745_10151370303782746",
91 "from": {
92 "category": "Shopping/retail",
93 "category_list": [
94 {
95 "id": "128003127270269",
96 "name": "Bike Shop"
97 }
98 ],
99 "name": "Western Cycle Source for Sports",
100 "id": "270843027745"
101 },
102 "story": "Western Cycle Source for Sports updated their cover photo.",
103 "story_tags": {
104 "0": [
105 {
106 "id": "270843027745",
107 "name": "Western Cycle Source for Sports",
108 "offset": 0,
109 "length": 31,
110 "type": "page"
111 }
112 ]
113 },
114 "picture": "https://fbcdn-photos-a.akamaihd.net/hphotos-ak-snc7/482418_10151370303672746_1924798223_s.jpg",
115 "link": "https://www.facebook.com/photo.php?fbid=10151370303672746&set=a.10150598301902746.381693.270843027745&type=1&relevant_count=1",
116 "icon": "https://fbstatic-a.akamaihd.net/rsrc.php/v2/yz/r/StEh3RhPvjk.gif",
117 "actions": [
118 {
119 "name": "Comment",
120 "link": "https://www.facebook.com/270843027745/posts/10151370303782746"
121 },
122 {
123 "name": "Like",
124 "link": "https://www.facebook.com/270843027745/posts/10151370303782746"
125 }
126 ],
127 "privacy": {
128 "value": ""
129 },
130 "place": {
131 "id": "270843027745",
132 "name": "Western Cycle Source for Sports",
133 "location": {
134 "street": "1550 8th Ave",
135 "city": "Regina",
136 "state": "SK",
137 "country": "Canada",
138 "zip": "S4R 1E4",
139 "latitude": 50.45679,
140 "longitude": -104.60276
141 }
142 },
143 "type": "photo",
144 "object_id": "10151370303672746",
145 "created_time": "2013-03-11T23:46:06+0000",
146 "updated_time": "2013-03-11T23:46:06+0000",
147 "likes": {
148 "data": [
149 {
150 "name": "Lou Schwindt",
151 "id": "57"
152 },
153 {
154 "name": "Maureen Daniel",
155 "id": "72"
156 },
157 {
158 "name": "Lee Watson",
159 "id": "696"
160 },
161 {
162 "name": "Rob Nelson",
163 "id": "40"
164 }
165 ],
166 "count": 10
167 },
168 "comments": {
169 "count": 0
170 }
171 },
172 {
173 "id": "161247843901324_629147610444676",
174 "from": {
175 "category": "Hotel",
176 "category_list": [
177 {
178 "id": "164243073639257",
179 "name": "Hotel"
180 }
181 ],
182 "name": "Best Western Denver Southwest",
183 "id": "161247843901324"
184 },
185 "message": "Today only -- Come meet Caroline and Meredith and Stanley the Stegosaurus (& Greg & Joe, too!) at the TechZulu Trend Lounge, Hilton Garden Inn 18th floor, 500 N Interstate 35, Austin, Texas. Monday, March 11th, 4:00pm to 7:00 pm. Also here Hannah Hart (My Drunk Kitchen) and Angry Video Game Nerd producer, Sean Keegan. Stanley is in the lobby.",
186 "picture": "https://fbcdn-photos-a.akamaihd.net/hphotos-ak-snc7/601266_629147587111345_968504279_s.jpg",
187 "link": "https://www.facebook.com/photo.php?fbid=629147587111345&set=a.173256162700492.47377.161247843901324&type=1&relevant_count=1",
188 "icon": "https://fbstatic-a.akamaihd.net/rsrc.php/v2/yz/r/StEh3RhPvjk.gif",
189 "actions": [
190 {
191 "name": "Comment",
192 "link": "https://www.facebook.com/161247843901324/posts/629147610444676"
193 },
194 {
195 "name": "Like",
196 "link": "https://www.facebook.com/161247843901324/posts/629147610444676"
197 }
198 ],
199 "privacy": {
200 "value": ""
201 },
202 "place": {
203 "id": "132709090079327",
204 "name": "Hilton Garden Inn Austin Downtown/Convention Center",
205 "location": {
206 "street": "500 North Interstate 35",
207 "city": "Austin",
208 "state": "TX",
209 "country": "United States",
210 "zip": "78701",
211 "latitude": 30.265384957204,
212 "longitude": -97.735604602521
213 }
214 },
215 "type": "photo",
216 "status_type": "added_photos",
217 "object_id": "629147587111345",
218 "created_time": "2013-03-11T20:49:10+0000",
219 "updated_time": "2013-03-11T23:51:25+0000",
220 "likes": {
221 "data": [
222 {
223 "name": "Andrew Henninger",
224 "id": "11"
225 },
226 {
227 "name": "Sarah Brents",
228 "id": "22"
229 },
230 {
231 "name": "Thomas Bush",
232 "id": "33"
233 },
234 {
235 "name": "Jennifer Tornetta",
236 "id": "44"
237 }
238 ],
239 "count": 84
240 },
241 "comments": {
242 "data": [
243 {
244 "id": "1612484301324_6294760444676_1126376",
245 "from": {
246 "name": "Amy Gibbs",
247 "id": "55"
248 },
249 "message": "You have to love a family that travels with their stegasaurus.",
250 "created_time": "2013-03-11T23:51:05+0000",
251 "likes": 2
252 },
253 {
254 "id": "1612843901324_6294761044676_1124378",
255 "from": {
256 "name": "Amy Gibbs",
257 "id": "55"
258 },
259 "message": "*stegosaurus...sorry!",
260 "created_time": "2013-03-11T23:51:25+0000",
261 "likes": 1
262 }
263 ],
264 "count": 11
265 }
266 },
267 {
268 "id": "104443_100085049977",
269 "from": {
270 "name": "Guy Frenchie",
271 "id": "1244414"
272 },
273 "story": "Guy Frenchie did some things with some stuff.",
274 "story_tags": {
275 "0": [
276 {
277 "id": "1244414",
278 "name": "Guy Frenchie",
279 "offset": 0,
280 "length": 16,
281 "type": "user"
282 }
283 ],
284 "26": [
285 {
286 "id": "37067557",
287 "name": "somebody",
288 "offset": 26,
289 "length": 10,
290 "type": "page"
291 }
292 ],
293 "48": [
294 {
295 "id": "50681138",
296 "name": "What do you think about things and stuff?",
297 "offset": 48,
298 "length": 52
299 }
300 ]
301 },
302 "icon": "https://fbstatic-a.akamaihd.net/rsrc.php/v2/yg/r/5PpICR5KcPe.png",
303 "actions": [
304 {
305 "name": "Comment",
306 "link": "https://www.facebook.com/1244414/posts/100085049977"
307 },
308 {
309 "name": "Like",
310 "link": "https://www.facebook.com/1244414/posts/100085049977"
311 }
312 ],
313 "privacy": {
314 "value": ""
315 },
316 "type": "question",
317 "object_id": "584616119",
318 "application": {
319 "name": "Questions",
320 "id": "101502535258"
321 },
322 "created_time": "2013-03-15T19:57:14+0000",
323 "updated_time": "2013-03-15T19:57:14+0000",
324 "likes": {
325 "data": [
326 {
327 "name": "Kevin Diner",
328 "id": "55520"
329 },
330 {
331 "name": "Bozo the Clown",
332 "id": "13960"
333 }
334 ],
335 "count": 3
336 },
337 "comments": {
338 "data": [
339 {
340 "id": "14446143_102008355988977_100927",
341 "from": {
342 "name": "Seymour Butts",
343 "id": "505677"
344 },
345 "message": "seems legit",
346 "created_time": "2013-03-13T12:20:19+0000",
347 "likes": 2
348 },
349 {
350 "id": "120143_1020035588977_1019440",
351 "from": {
352 "name": "Andre the Giant",
353 "id": "100390199"
354 },
355 "message": "Anybody want a peanut?",
356 "created_time": "2013-03-13T12:23:25+0000"
357 }
358 ],
359 "count": 22
360 }
361 }
362 ],
363 "paging": {
364 "previous": "https://graph.facebook.com/me/home&limit=25&since=1234",
365 "next": "https://graph.facebook.com/me/home&limit=25&until=4321"
366 }
367}
3368
=== modified file 'friends/tests/data/flickr-full.dat'
--- friends/tests/data/flickr-full.dat 2012-10-20 15:35:30 +0000
+++ friends/tests/data/flickr-full.dat 2013-03-20 13:27:53 +0000
@@ -1,1 +1,13 @@
1{"photos": {"photo": [{"username": "Geddy Lee", "secret": "abc", "title": "ant", "owner": "123", "id": "801", "dateupload": "2012-05-10T13:36:45", "server": "1"}, {"username": "Alex Lifeson", "secret": "def", "ownername": "Alex Lifeson", "title": "bee", "owner": "456", "id": "802", "server": "1"}, {"username": "Neil Peart", "title": "cat", "farm": "animalz", "server": "1", "iconserver": "9", "secret": "ghi", "ownername": "Bob Dobbs", "owner": "789", "id": "803", "iconfarm": "iconz"}]}}1{ "photos": {
2 "photo": [
3 { "id": "8552892154", "secret": "a", "server": "8378", "farm": 9, "owner": "47303164@N00", "username": "raise my voice", "title": "Chocolate chai #yegcoffee", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363117902", "ownername": "raise my voice", "iconserver": 93, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 },
4 { "id": "8552845358", "secret": "b", "server": "8085", "farm": 9, "owner": "47303164@N00", "username": "raise my voice", "title": "Torah ark #yegjew", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363116818", "ownername": "raise my voice", "iconserver": 93, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 },
5 { "id": "8552661200", "secret": "c", "server": "8522", "farm": 9, "owner": "60551783@N00", "username": "Reinhard.Pantke", "title": "Henningsvaer", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363112533", "ownername": "Reinhard.Pantke", "iconserver": 8, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 },
6 { "id": "8550946245", "secret": "d", "server": "8107", "farm": 9, "owner": "60551783@N00", "username": "Reinhard.Pantke", "title": "Summerfeeling on Lofoten", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363098878", "ownername": "Reinhard.Pantke", "iconserver": 8, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 },
7 { "id": "8550829193", "secret": "e", "server": "8246", "farm": 9, "owner": "27204141@N05", "username": "Nelson Webb", "title": "St. Michael - The Archangel", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363096450", "ownername": "Nelson Webb", "iconserver": "2047", "iconfarm": 3, "latitude": 53.833156, "longitude": -112.330784, "accuracy": 15, "context": 0, "place_id": "4Y55lnhZVrNO", "woeid": "8496", "geo_is_family": 0, "geo_is_friend": 0, "geo_is_contact": 0, "geo_is_public": 1 },
8 { "id": "8551930826", "secret": "f", "server": "8247", "farm": 9, "owner": "27204141@N05", "username": "Nelson Webb", "title": "Pine scented air freshener", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363096449", "ownername": "Nelson Webb", "iconserver": "2047", "iconfarm": 3, "latitude": 53.878136, "longitude": -112.335162, "accuracy": 15, "context": 0, "place_id": "4Y55lnhZVrNO", "woeid": "8496", "geo_is_family": 0, "geo_is_friend": 0, "geo_is_contact": 0, "geo_is_public": 1 },
9 { "id": "8549658873", "secret": "g", "server": "8239", "farm": 9, "owner": "30584843@N00", "username": "Mark Iocchelli", "title": "Sleepy Hollow", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363055714", "ownername": "Mark Iocchelli", "iconserver": 22, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 },
10 { "id": "8548811967", "secret": "h", "server": "8229", "farm": 9, "owner": "47303164@N00", "username": "raise my voice", "title": "Trying out The Wokkery #yegfood", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363028798", "ownername": "raise my voice", "iconserver": 93, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 },
11 { "id": "8548753789", "secret": "i", "server": "8512", "farm": 9, "owner": "30584843@N00", "username": "Mark Iocchelli", "title": "Alberta Rail Pipeline", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363027071", "ownername": "Mark Iocchelli", "iconserver": 22, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 },
12 { "id": "8549607582", "secret": "j", "server": "8087", "farm": 9, "owner": "60404254@N00", "username": "tavis_mcnally", "title": "26 weeks", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363022277", "ownername": "tavis_mcnally", "iconserver": "2182", "iconfarm": 3, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 }
13 ], "total": "290", "page": 1, "per_page": 10, "pages": 29 }, "stat": "ok" }
214
=== modified file 'friends/tests/mocks.py'
--- friends/tests/mocks.py 2013-02-19 17:00:41 +0000
+++ friends/tests/mocks.py 2013-03-20 13:27:53 +0000
@@ -30,15 +30,19 @@
30import hashlib30import hashlib
31import logging31import logging
32import threading32import threading
33import tempfile
34import shutil
3335
34from io import StringIO36from io import StringIO
35from logging.handlers import QueueHandler37from logging.handlers import QueueHandler
36from pkg_resources import resource_listdir, resource_string38from pkg_resources import resource_listdir, resource_string
37from queue import Empty, Queue39from queue import Empty, Queue
38from urllib.parse import urlsplit40from urllib.parse import urlsplit
41from gi.repository import Dee
3942
40from friends.utils.base import Base43from friends.utils.base import Base
41from friends.utils.logging import LOG_FORMAT44from friends.utils.logging import LOG_FORMAT
45from friends.utils.model import COLUMN_TYPES
4246
4347
44try:48try:
@@ -51,6 +55,50 @@
51NEWLINE = '\n'55NEWLINE = '\n'
5256
5357
58# Create a test model that will not interfere with the user's environment.
59# We'll use this object as a mock of the real model.
60TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
61TestModel.set_schema_full(COLUMN_TYPES)
62
63
64@mock.patch('friends.utils.http._soup', mock.Mock())
65@mock.patch('friends.utils.base.Model', TestModel)
66@mock.patch('friends.utils.base.Base._get_access_token',
67 mock.Mock(return_value='Access Tolkien'))
68def populate_fake_data():
69 """Dump a mixture of random data from our testsuite into TestModel.
70
71 This is invoked by running 'friends-dispatcher --test' so that you
72 can have some phony data in the model to test against.
73
74 Just remember that the data appears in a separate model so as not
75 to interfere with the user's official DeeModel stream.
76 """
77 from friends.utils.cache import JsonCache
78 from friends.protocols.facebook import Facebook
79 from friends.protocols.flickr import Flickr
80 from friends.protocols.twitter import Twitter
81 from gi.repository import Dee
82
83 temp_cache = tempfile.mkdtemp()
84 root = JsonCache._root = os.path.join(temp_cache, '{}.json')
85
86 protocols = {
87 'facebook-full.dat': Facebook(FakeAccount(account_id=1)),
88 'flickr-full.dat': Flickr(FakeAccount(account_id=2)),
89 'twitter-home.dat': Twitter(FakeAccount(account_id=3)),
90 }
91
92 for fake_name, protocol in protocols.items():
93 protocol.source_registry = EDSRegistry()
94 with mock.patch('friends.utils.http.Soup.Message',
95 FakeSoupMessage('friends.tests.data',
96 fake_name)) as fake:
97 protocol.receive()
98
99 shutil.rmtree(temp_cache)
100
101
54class FakeAuth:102class FakeAuth:
55 id = 'fakeauth id'103 id = 'fakeauth id'
56 method = 'fakeauth method'104 method = 'fakeauth method'
@@ -61,7 +109,7 @@
61class FakeAccount:109class FakeAccount:
62 """A fake account object for testing purposes."""110 """A fake account object for testing purposes."""
63111
64 def __init__(self, service=None):112 def __init__(self, service=None, account_id=88):
65 self.access_token = None113 self.access_token = None
66 self.secret_token = None114 self.secret_token = None
67 self.user_full_name = None115 self.user_full_name = None
@@ -69,7 +117,7 @@
69 self.user_id = None117 self.user_id = None
70 self.auth = FakeAuth()118 self.auth = FakeAuth()
71 self.login_lock = threading.Lock()119 self.login_lock = threading.Lock()
72 self.id = '1234'120 self.id = account_id
73 self.protocol = Base(self)121 self.protocol = Base(self)
74122
75123
76124
=== modified file 'friends/tests/test_account.py'
--- friends/tests/test_account.py 2013-02-05 01:11:35 +0000
+++ friends/tests/test_account.py 2013-03-20 13:27:53 +0000
@@ -23,19 +23,11 @@
2323
24import unittest24import unittest
2525
26from gi.repository import Dee
27
28from friends.errors import UnsupportedProtocolError26from friends.errors import UnsupportedProtocolError
29from friends.protocols.flickr import Flickr27from friends.protocols.flickr import Flickr
30from friends.tests.mocks import FakeAccount, LogMock, SettingsIterMock, mock28from friends.tests.mocks import FakeAccount, LogMock, SettingsIterMock
29from friends.tests.mocks import TestModel, mock
31from friends.utils.account import Account, AccountManager30from friends.utils.account import Account, AccountManager
32from friends.utils.model import COLUMN_TYPES
33
34
35# Create a test model that will not interfere with the user's environment.
36# We'll use this object as a mock of the real model.
37TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
38TestModel.set_schema_full(COLUMN_TYPES)
3931
4032
41class TestAccount(unittest.TestCase):33class TestAccount(unittest.TestCase):
@@ -159,7 +151,6 @@
159 assert self.account != None151 assert self.account != None
160152
161153
162
163accounts_manager = mock.Mock()154accounts_manager = mock.Mock()
164accounts_manager.new_for_service_type(155accounts_manager.new_for_service_type(
165 'microblogging').get_enabled_account_services.return_value = []156 'microblogging').get_enabled_account_services.return_value = []
@@ -199,7 +190,7 @@
199 # the account manager's mapping.190 # the account manager's mapping.
200 manager = AccountManager()191 manager = AccountManager()
201 manager._add_new_account(self.account_service)192 manager._add_new_account(self.account_service)
202 self.assertIn('1234', manager._accounts)193 self.assertIn(88, manager._accounts)
203194
204 def test_account_manager_enabled_event(self):195 def test_account_manager_enabled_event(self):
205 manager = AccountManager()196 manager = AccountManager()
@@ -210,56 +201,6 @@
210 manager._on_enabled_event(accounts_manager, 2)201 manager._on_enabled_event(accounts_manager, 2)
211 account.protocol.assert_called_once_with('receive')202 account.protocol.assert_called_once_with('receive')
212203
213 def test_account_manager_delete_account_no_account(self):
214 # Deleting an account removes the global_id from the mapping. But if
215 # that global id is missing, then it does not cause an exception.
216 manager = AccountManager()
217 manager._get_service = mock.Mock()
218 manager._get_service.return_value = self.account_service
219 self.assertNotIn('1234', manager._accounts)
220 manager._on_account_deleted(accounts_manager, '1234')
221 self.assertNotIn('1234', manager._accounts)
222
223 @mock.patch('friends.utils.base.Model', TestModel)
224 @mock.patch('friends.utils.base._seen_ids', {})
225 def test_account_manager_delete_account(self):
226 # Deleting an account removes the id from the mapping. But if
227 # that id is missing, then it does not cause an exception.
228 manager = AccountManager()
229 manager._get_service = mock.Mock()
230 manager._get_service.return_value = self.account_service
231 manager._add_new_account(self.account_service)
232 self.assertIn('1234', manager._accounts)
233 manager._on_account_deleted(accounts_manager, '1234')
234 self.assertNotIn('1234', manager._accounts)
235
236 @mock.patch('friends.utils.base.Model', TestModel)
237 @mock.patch('friends.utils.base._seen_ids', {})
238 def test_account_manager_delete_account_preserve_messages(self):
239 # Deleting an Account should not delete messages from the row
240 # that exist on other protocols too.
241 manager = AccountManager()
242 manager._get_service = mock.Mock()
243 manager._get_service.return_value = self.account_service
244 manager._add_new_account(self.account_service)
245 example_row = [[['twitter', '6', '1234'],
246 ['base', '1234', '5678']],
247 'messages', 'Fred Flintstone', '', 'fred', True,
248 '2012-08-28T19:59:34', 'Yabba dabba dooooo!', '', '',
249 0.0, False, '', '', '', '', '', '']
250 result_row = [[['twitter', '6', '1234']],
251 'messages', 'Fred Flintstone', '', 'fred', True,
252 '2012-08-28T19:59:34', 'Yabba dabba dooooo!', '', '',
253 0.0, False, '', '', '', '', '', '']
254 row_iter = TestModel.append(*example_row)
255 from friends.utils.base import _seen_ids
256 _seen_ids[
257 ('base', '1234', '5678')
258 ] = TestModel.get_position(row_iter)
259 self.assertEqual(list(TestModel.get_row(0)), example_row)
260 manager._on_account_deleted(accounts_manager, '1234')
261 self.assertEqual(list(TestModel.get_row(0)), result_row)
262
263204
264@mock.patch('gi.repository.Accounts.Manager', accounts_manager)205@mock.patch('gi.repository.Accounts.Manager', accounts_manager)
265class TestAccountManagerRealAccount(unittest.TestCase):206class TestAccountManagerRealAccount(unittest.TestCase):
266207
=== modified file 'friends/tests/test_cache.py'
--- friends/tests/test_cache.py 2013-03-08 02:31:15 +0000
+++ friends/tests/test_cache.py 2013-03-20 13:27:53 +0000
@@ -21,14 +21,10 @@
2121
2222
23import os23import os
24import time
25import shutil24import shutil
26import tempfile25import tempfile
27import unittest26import unittest
2827
29from datetime import date, timedelta
30from pkg_resources import resource_filename
31
32from friends.utils.cache import JsonCache28from friends.utils.cache import JsonCache
3329
3430
3531
=== modified file 'friends/tests/test_dispatcher.py'
--- friends/tests/test_dispatcher.py 2013-02-19 17:00:41 +0000
+++ friends/tests/test_dispatcher.py 2013-03-20 13:27:53 +0000
@@ -26,7 +26,7 @@
2626
27from dbus.mainloop.glib import DBusGMainLoop27from dbus.mainloop.glib import DBusGMainLoop
2828
29from friends.service.dispatcher import Dispatcher, STUB29from friends.service.dispatcher import Dispatcher, ManageTimers, STUB
30from friends.tests.mocks import LogMock, mock30from friends.tests.mocks import LogMock, mock
3131
3232
@@ -61,7 +61,11 @@
61 self.dispatcher.account_manager.get_all.assert_called_once_with()61 self.dispatcher.account_manager.get_all.assert_called_once_with()
62 account.protocol.assert_called_once_with('receive')62 account.protocol.assert_called_once_with('receive')
6363
64 self.assertEqual(self.log_mock.empty(), 'Refresh requested\n')64 self.assertEqual(self.log_mock.empty(),
65 'Clearing 1 shutdown timer(s)...\n'
66 'Refresh requested\n'
67 'Clearing 0 shutdown timer(s)...\n'
68 'Starting new shutdown timer...\n')
6569
66 def test_clear_indicators(self):70 def test_clear_indicators(self):
67 self.dispatcher.menu_manager = mock.Mock()71 self.dispatcher.menu_manager = mock.Mock()
@@ -81,7 +85,10 @@
81 'like', '23346356767354626', success=STUB, failure=STUB)85 'like', '23346356767354626', success=STUB, failure=STUB)
8286
83 self.assertEqual(self.log_mock.empty(),87 self.assertEqual(self.log_mock.empty(),
84 '345: like 23346356767354626\n')88 'Clearing 1 shutdown timer(s)...\n'
89 '345: like 23346356767354626\n'
90 'Clearing 0 shutdown timer(s)...\n'
91 'Starting new shutdown timer...\n')
8592
86 def test_failing_do(self):93 def test_failing_do(self):
87 account = mock.Mock()94 account = mock.Mock()
@@ -93,7 +100,10 @@
93 self.assertEqual(account.protocol.call_count, 0)100 self.assertEqual(account.protocol.call_count, 0)
94101
95 self.assertEqual(self.log_mock.empty(),102 self.assertEqual(self.log_mock.empty(),
96 'Could not find account: 6\n')103 'Clearing 1 shutdown timer(s)...\n'
104 'Could not find account: 6\n'
105 'Clearing 0 shutdown timer(s)...\n'
106 'Starting new shutdown timer...\n')
97107
98 def test_send_message(self):108 def test_send_message(self):
99 account1 = mock.Mock()109 account1 = mock.Mock()
@@ -128,7 +138,10 @@
128 success=STUB, failure=STUB)138 success=STUB, failure=STUB)
129139
130 self.assertEqual(self.log_mock.empty(),140 self.assertEqual(self.log_mock.empty(),
131 'Replying to 2, objid\n')141 'Clearing 1 shutdown timer(s)...\n'
142 'Replying to 2, objid\n'
143 'Clearing 0 shutdown timer(s)...\n'
144 'Starting new shutdown timer...\n')
132145
133 def test_send_reply_failed(self):146 def test_send_reply_failed(self):
134 account = mock.Mock()147 account = mock.Mock()
@@ -140,8 +153,11 @@
140 self.assertEqual(account.protocol.call_count, 0)153 self.assertEqual(account.protocol.call_count, 0)
141154
142 self.assertEqual(self.log_mock.empty(),155 self.assertEqual(self.log_mock.empty(),
143 'Replying to 2, objid\n' +156 'Clearing 1 shutdown timer(s)...\n'
144 'Could not find account: 2\n')157 'Replying to 2, objid\n'
158 'Could not find account: 2\n'
159 'Clearing 0 shutdown timer(s)...\n'
160 'Starting new shutdown timer...\n')
145161
146 def test_upload_async(self):162 def test_upload_async(self):
147 account = mock.Mock()163 account = mock.Mock()
@@ -166,7 +182,10 @@
166 )182 )
167183
168 self.assertEqual(self.log_mock.empty(),184 self.assertEqual(self.log_mock.empty(),
169 'Uploading file://path/to/image.png to 2\n')185 'Clearing 1 shutdown timer(s)...\n'
186 'Uploading file://path/to/image.png to 2\n'
187 'Clearing 0 shutdown timer(s)...\n'
188 'Starting new shutdown timer...\n')
170189
171 def test_get_features(self):190 def test_get_features(self):
172 self.assertEqual(json.loads(self.dispatcher.GetFeatures('facebook')),191 self.assertEqual(json.loads(self.dispatcher.GetFeatures('facebook')),
@@ -205,6 +224,52 @@
205 self.dispatcher.URLShorten(long_url),224 self.dispatcher.URLShorten(long_url),
206 'short url')225 'short url')
207 lookup_mock.is_shortened.assert_called_once_with(long_url)226 lookup_mock.is_shortened.assert_called_once_with(long_url)
208 self.dispatcher.settings.get_boolean.assert_called_once_with('shorten-urls')227 self.dispatcher.settings.get_boolean.assert_called_once_with(
228 'shorten-urls')
209 lookup_mock.lookup.assert_called_once_with('is.gd')229 lookup_mock.lookup.assert_called_once_with('is.gd')
210 lookup_mock.lookup.return_value.shorten.assert_called_once_with(long_url)230 lookup_mock.lookup.return_value.shorten.assert_called_once_with(
231 long_url)
232
233 @mock.patch('friends.service.dispatcher.GLib')
234 def test_manage_timers_clear(self, glib):
235 manager = ManageTimers()
236 manager.timers = {1}
237 manager.__enter__()
238 glib.source_remove.assert_called_once_with(1)
239 manager.timers = {1, 2, 3}
240 manager.clear_all_timers()
241 self.assertEqual(glib.source_remove.call_count, 4)
242
243 @mock.patch('friends.service.dispatcher.GLib')
244 def test_manage_timers_set(self, glib):
245 manager = ManageTimers()
246 manager.timers = set()
247 manager.clear_all_timers = mock.Mock()
248 manager.__exit__()
249 glib.timeout_add_seconds.assert_called_once_with(30, manager.terminate)
250 manager.clear_all_timers.assert_called_once_with()
251 self.assertEqual(len(manager.timers), 1)
252
253 @mock.patch('friends.service.dispatcher.persist_model')
254 @mock.patch('friends.service.dispatcher.threading')
255 @mock.patch('friends.service.dispatcher.GLib')
256 def test_manage_timers_terminate(self, glib, thread, persist):
257 manager = ManageTimers()
258 manager.timers = set()
259 thread.activeCount.return_value = 1
260 manager.terminate()
261 thread.activeCount.assert_called_once_with()
262 persist.assert_called_once_with()
263 glib.idle_add.assert_called_once_with(manager.callback)
264
265 @mock.patch('friends.service.dispatcher.persist_model')
266 @mock.patch('friends.service.dispatcher.threading')
267 @mock.patch('friends.service.dispatcher.GLib')
268 def test_manage_timers_dont_kill_threads(self, glib, thread, persist):
269 manager = ManageTimers()
270 manager.timers = set()
271 manager.set_new_timer = mock.Mock()
272 thread.activeCount.return_value = 10
273 manager.terminate()
274 thread.activeCount.assert_called_once_with()
275 manager.set_new_timer.assert_called_once_with()
211276
=== modified file 'friends/tests/test_facebook.py'
--- friends/tests/test_facebook.py 2013-02-27 22:22:38 +0000
+++ friends/tests/test_facebook.py 2013-03-20 13:27:53 +0000
@@ -15,27 +15,26 @@
1515
16"""Test the Facebook plugin."""16"""Test the Facebook plugin."""
1717
18
18__all__ = [19__all__ = [
19 'TestFacebook',20 'TestFacebook',
20 ]21 ]
2122
2223
24import os
25import tempfile
23import unittest26import unittest
27import shutil
2428
25from gi.repository import Dee, GLib29from gi.repository import GLib
26from pkg_resources import resource_filename30from pkg_resources import resource_filename
2731
28from friends.protocols.facebook import Facebook32from friends.protocols.facebook import Facebook
29from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock, mock33from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
34from friends.tests.mocks import TestModel, mock
30from friends.tests.mocks import EDSBookClientMock, EDSSource, EDSRegistry35from friends.tests.mocks import EDSBookClientMock, EDSSource, EDSRegistry
31from friends.errors import ContactsError, FriendsError, AuthorizationError36from friends.errors import ContactsError, FriendsError, AuthorizationError
32from friends.utils.model import COLUMN_TYPES37from friends.utils.cache import JsonCache
33
34
35# Create a test model that will not interfere with the user's environment.
36# We'll use this object as a mock of the real model.
37TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
38TestModel.set_schema_full(COLUMN_TYPES)
3938
4039
41@mock.patch('friends.utils.http._soup', mock.Mock())40@mock.patch('friends.utils.http._soup', mock.Mock())
@@ -44,12 +43,16 @@
44 """Test the Facebook API."""43 """Test the Facebook API."""
4544
46 def setUp(self):45 def setUp(self):
46 self._temp_cache = tempfile.mkdtemp()
47 self._root = JsonCache._root = os.path.join(
48 self._temp_cache, '{}.json')
47 self.account = FakeAccount()49 self.account = FakeAccount()
48 self.protocol = Facebook(self.account)50 self.protocol = Facebook(self.account)
49 self.protocol.source_registry = EDSRegistry()51 self.protocol.source_registry = EDSRegistry()
5052
51 def tearDown(self):53 def tearDown(self):
52 TestModel.clear()54 TestModel.clear()
55 shutil.rmtree(self._temp_cache)
5356
54 def test_features(self):57 def test_features(self):
55 # The set of public features.58 # The set of public features.
@@ -106,75 +109,153 @@
106 # Receive the wall feed for a user.109 # Receive the wall feed for a user.
107 self.maxDiff = None110 self.maxDiff = None
108 self.account.access_token = 'abc'111 self.account.access_token = 'abc'
109 self.assertEqual(self.protocol.receive(), 4)112 self.assertEqual(self.protocol.receive(), 12)
110 self.assertEqual(TestModel.get_n_rows(), 4)113 self.assertEqual(TestModel.get_n_rows(), 12)
114 self.assertEqual(list(TestModel.get_row(0)), [
115 'facebook',
116 88,
117 'fake_id',
118 'mentions',
119 'Yours Truly',
120 '56789',
121 'Yours Truly',
122 False,
123 '2013-03-13T23:29:07Z',
124 'Writing code that supports geotagging data from facebook. ' +
125 'If y\'all could make some geotagged facebook posts for me ' +
126 'to test with, that\'d be super.',
127 GLib.get_user_cache_dir() +
128 '/friends/avatars/5c4e74c64b1a09343558afc1046c2b1d176a2ba2',
129 'https://www.facebook.com/56789',
130 1,
131 False,
132 '',
133 '',
134 '',
135 '',
136 '',
137 '',
138 'Victoria, British Columbia',
139 48.4333,
140 -123.35,
141 ])
111 self.assertEqual(list(TestModel.get_row(2)), [142 self.assertEqual(list(TestModel.get_row(2)), [
112 [['facebook',143 'facebook',
113 '1234',144 88,
114 '117402931676347_386054134801436_3235476']],145 'faker than cake!',
115 'reply_to/109',146 'reply_to/fake_id',
116 'Bruce Peart',147 'Father',
117 '809',148 '234',
118 'Bruce Peart',149 'Father',
119 False,150 False,
120 '2012-09-26T17:16:00Z',151 '2013-03-12T23:29:45Z',
121 'OK Don...10) Headlong Flight',152 'don\'t know how',
122 GLib.get_user_cache_dir() +153 GLib.get_user_cache_dir() +
123 '/friends/avatars/b688c8def0455d4a3853d5fcdfaf0708645cfd3e',154 '/friends/avatars/9b9379ccc7948e4804dff7914bfa4c6de3974df5',
124 'https://www.facebook.com/809',155 'https://www.facebook.com/234',
125 0.0,156 0,
126 False,157 False,
127 '',158 '',
128 '',159 '',
129 '',160 '',
130 '',161 '',
131 '',162 '',
132 ''])163 '',
133 self.assertEqual(list(TestModel.get_row(0)), [164 '',
134 [['facebook', '1234', '108']],165 0.0,
135 'mentions',166 0.0,
136 'Rush is a Band',167 ])
137 '117402931676347',168 self.assertEqual(list(TestModel.get_row(6)), [
138 'Rush is a Band',169 'facebook',
139 False,170 88,
140 '2012-09-26T17:34:00Z',171 '161247843901324_629147610444676',
141 'Rush takes off to the Great White North',172 'mentions',
142 GLib.get_user_cache_dir() +173 'Best Western Denver Southwest',
143 '/friends/avatars/7d1a70e6998f4a38954e93ca03d689463f71d63b',174 '161247843901324',
144 'https://www.facebook.com/117402931676347',175 'Best Western Denver Southwest',
145 16.0,176 False,
146 False,177 '2013-03-11T23:51:25Z',
147 'https://fbexternal-a.akamaihd.net/rush.jpg',178 'Today only -- Come meet Caroline and Meredith and Stanley the ' +
148 'Rush is a Band Blog',179 'Stegosaurus (& Greg & Joe, too!) at the TechZulu Trend Lounge, ' +
149 'http://www.rushisaband.com/blog/Rush-Clockwork-Angels-tour',180 'Hilton Garden Inn 18th floor, 500 N Interstate 35, Austin, ' +
150 'Rush is a Band: Neil Peart, Geddy Lee, Alex Lifeson',181 'Texas. Monday, March 11th, 4:00pm to 7:00 pm. Also here ' +
151 'www.rushisaband.com',182 'Hannah Hart (My Drunk Kitchen) and Angry Video Game Nerd ' +
152 ''])183 'producer, Sean Keegan. Stanley is in the lobby.',
153 self.assertEqual(list(TestModel.get_row(1)), [184 GLib.get_user_cache_dir() +
154 [['facebook', '1234', '109']],185 '/friends/avatars/5b2d70e788df790b9c8db4c6a138fc4a1f433ec9',
155 'mentions',186 'https://www.facebook.com/161247843901324',
156 'Rush is a Band',187 84,
157 '117402931676347',188 False,
158 'Rush is a Band',189 'https://fbcdn-photos-a.akamaihd.net/hphotos-ak-snc7/' +
159 False,190 '601266_629147587111345_968504279_s.jpg',
160 '2012-09-26T17:49:06Z',191 '',
161 'http://www2.gibson.com/Alex-Lifeson-0225-2011.aspx',192 'https://www.facebook.com/photo.php?fbid=629147587111345&set=a.173256162700492.47377.161247843901324&type=1&relevant_count=1',
162 GLib.get_user_cache_dir() +193 '',
163 '/friends/avatars/7d1a70e6998f4a38954e93ca03d689463f71d63b',194 '',
164 'https://www.facebook.com/117402931676347',195 '',
165 27.0,196 'Hilton Garden Inn Austin Downtown/Convention Center',
166 False,197 30.265384957204,
167 'https://images.gibson.com/Rush_Clockwork-Angels_t.jpg',198 -97.735604602521,
168 'Top 10 Alex Lifeson Guitar Moments',199 ])
169 'http://www2.gibson.com/Alex-Lifeson.aspx',200 self.assertEqual(list(TestModel.get_row(9)), [
170 'For millions of Rush fans old and new, it’s a pleasure',201 'facebook',
171 'www2.gibson.com',202 88,
172 ''])203 '104443_100085049977',
204 'mentions',
205 'Guy Frenchie',
206 '1244414',
207 'Guy Frenchie',
208 False,
209 '2013-03-15T19:57:14Z',
210 'Guy Frenchie did some things with some stuff.',
211 GLib.get_user_cache_dir() +
212 '/friends/avatars/3f5e276af0c43f6411d931b829123825ede1968e',
213 'https://www.facebook.com/1244414',
214 3,
215 False,
216 '',
217 '',
218 '',
219 '',
220 '',
221 '',
222 '',
223 0.0,
224 0.0,
225 ])
173226
174 # XXX We really need full coverage of the receive() method, including227 # XXX We really need full coverage of the receive() method, including
175 # cases where some data is missing, or can't be converted228 # cases where some data is missing, or can't be converted
176 # (e.g. timestamps), and paginations.229 # (e.g. timestamps), and paginations.
177230
231 @mock.patch('friends.utils.base.Model', TestModel)
232 @mock.patch('friends.utils.http.Soup.Message',
233 FakeSoupMessage('friends.tests.data', 'facebook-full.dat'))
234 @mock.patch('friends.protocols.facebook.Facebook._login',
235 return_value=True)
236 @mock.patch('friends.utils.base._seen_ids', {})
237 def test_home_since_id(self, *mocks):
238 self.account.access_token = 'access'
239 self.account.secret_token = 'secret'
240 self.account.auth.parameters = dict(
241 ConsumerKey='key',
242 ConsumerSecret='secret')
243 self.assertEqual(self.protocol.home(), 12)
244
245 with open(self._root.format('facebook_ids'), 'r') as fd:
246 self.assertEqual(fd.read(), '{"messages": "2013-03-15T19:57:14Z"}')
247
248 follow = self.protocol._follow_pagination = mock.Mock()
249 follow.return_value = []
250 self.assertEqual(self.protocol.home(), 12)
251 follow.assert_called_once_with(
252 'https://graph.facebook.com/me/home',
253 dict(limit=50,
254 since='2013-03-15T19:57:14Z',
255 access_token='access',
256 )
257 )
258
178 @mock.patch('friends.protocols.facebook.Downloader')259 @mock.patch('friends.protocols.facebook.Downloader')
179 def test_send_to_my_wall(self, dload):260 def test_send_to_my_wall(self, dload):
180 dload().get_json.return_value = dict(id='post_id')261 dload().get_json.return_value = dict(id='post_id')
181262
=== modified file 'friends/tests/test_flickr.py'
--- friends/tests/test_flickr.py 2013-02-27 23:04:36 +0000
+++ friends/tests/test_flickr.py 2013-03-20 13:27:53 +0000
@@ -22,18 +22,12 @@
2222
23import unittest23import unittest
2424
25from gi.repository import GLib, Dee25from gi.repository import GLib
2626
27from friends.errors import AuthorizationError, FriendsError27from friends.errors import AuthorizationError, FriendsError
28from friends.protocols.flickr import Flickr28from friends.protocols.flickr import Flickr
29from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock, mock29from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
30from friends.utils.model import COLUMN_INDICES, COLUMN_TYPES30from friends.tests.mocks import TestModel, mock
31
32
33# Create a test model that will not interfere with the user's environment.
34# We'll use this object as a mock of the real model.
35TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
36TestModel.set_schema_full(COLUMN_TYPES)
3731
3832
39@mock.patch('friends.utils.http._soup', mock.Mock())33@mock.patch('friends.utils.http._soup', mock.Mock())
@@ -133,7 +127,7 @@
133 'http://api.flickr.com/services/rest',127 'http://api.flickr.com/services/rest',
134 method='GET',128 method='GET',
135 params=dict(129 params=dict(
136 extras='date_upload,owner_name,icon_server',130 extras='date_upload,owner_name,icon_server,geo',
137 format='json',131 format='json',
138 nojsoncallback='1',132 nojsoncallback='1',
139 api_key='fake',133 api_key='fake',
@@ -157,77 +151,66 @@
157 @mock.patch('friends.utils.base.Model', TestModel)151 @mock.patch('friends.utils.base.Model', TestModel)
158 def test_flickr_data(self):152 def test_flickr_data(self):
159 # Start by setting up a fake account id.153 # Start by setting up a fake account id.
160 self.account.id = 'lerxst'154 self.account.id = 69
161 with mock.patch.object(self.protocol, '_get_access_token',155 with mock.patch.object(self.protocol, '_get_access_token',
162 return_value='token'):156 return_value='token'):
163 self.assertEqual(self.protocol.receive(), 3)157 self.assertEqual(self.protocol.receive(), 10)
164 self.assertEqual(TestModel.get_n_rows(), 3)158 self.assertEqual(TestModel.get_n_rows(), 10)
165159
166 self.assertEqual(160 self.assertEqual(
167 list(TestModel.get_row(0)),161 list(TestModel.get_row(0)),
168 [[['flickr', 'lerxst', '801']],162 ['flickr',
169 'images',163 69,
170 '',164 '8552892154',
171 '123',165 'images',
172 '',166 'raise my voice',
173 False,167 '47303164@N00',
174 '2012-05-10T13:36:45Z',168 'raise my voice',
175 '',169 True,
176 '',170 '2013-03-12T19:51:42Z',
177 '',171 '',
178 0.0,172 GLib.get_user_cache_dir() +
179 False,173 '/friends/avatars/7b30ff0140dd9b80f2b1782a2802c3ce785fa0ce',
180 '',174 'http://www.flickr.com/people/47303164@N00',
181 '',175 0,
182 '',176 False,
183 '',177 'http://farm9.static.flickr.com/8378/47303164@N00_a_m.jpg',
184 'ant',178 '',
185 '',179 'http://farm9.static.flickr.com/8378/47303164@N00_a_b.jpg',
186 ])180 '',
187181 'Chocolate chai #yegcoffee',
188 self.assertEqual(182 'http://farm9.static.flickr.com/8378/47303164@N00_a_t.jpg',
189 list(TestModel.get_row(1)),183 '',
190 [[['flickr', 'lerxst', '802']],184 0.0,
191 'images',185 0.0,
192 'Alex Lifeson',186 ])
193 '456',187
194 'Alex Lifeson',188 self.assertEqual(
195 True,189 list(TestModel.get_row(4)),
196 '',190 ['flickr',
197 '',191 69,
198 '',192 '8550829193',
199 '',193 'images',
200 0.0,194 'Nelson Webb',
201 False,195 '27204141@N05',
202 '',196 'Nelson Webb',
203 '',197 True,
204 '',198 '2013-03-12T13:54:10Z',
205 '',199 '',
206 'bee',200 GLib.get_user_cache_dir() +
207 '',201 '/friends/avatars/cae2939354a33fea5f008df91bb8e25920be5dc3',
208 ])202 'http://www.flickr.com/people/27204141@N05',
209203 0,
210 self.assertEqual(204 False,
211 list(TestModel.get_row(2)),205 'http://farm9.static.flickr.com/8246/27204141@N05_e_m.jpg',
212 [[['flickr', 'lerxst', '803']],206 '',
213 'images',207 'http://farm9.static.flickr.com/8246/27204141@N05_e_b.jpg',
214 'Bob Dobbs',208 '',
215 '789',209 'St. Michael - The Archangel',
216 'Bob Dobbs',210 'http://farm9.static.flickr.com/8246/27204141@N05_e_t.jpg',
217 False,211 '',
218 '',212 53.833156,
219 '',213 -112.330784,
220 GLib.get_user_cache_dir() +
221 '/friends/avatars/b913501d6face9d13f3006b731a711b596d23099',
222 'http://www.flickr.com/people/789',
223 0.0,
224 False,
225 'http://farmanimalz.static.flickr.com/1/789_ghi_m.jpg',
226 '',
227 'http://farmanimalz.static.flickr.com/1/789_ghi_b.jpg',
228 '',
229 'cat',
230 'http://farmanimalz.static.flickr.com/1/789_ghi_t.jpg',
231 ])214 ])
232215
233 @mock.patch('friends.utils.http.Soup.form_request_new_from_multipart',216 @mock.patch('friends.utils.http.Soup.form_request_new_from_multipart',
234217
=== modified file 'friends/tests/test_foursquare.py'
--- friends/tests/test_foursquare.py 2013-02-26 19:13:31 +0000
+++ friends/tests/test_foursquare.py 2013-03-20 13:27:53 +0000
@@ -22,20 +22,12 @@
2222
23import unittest23import unittest
2424
25from gi.repository import Dee
26
27from friends.protocols.foursquare import FourSquare25from friends.protocols.foursquare import FourSquare
28from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock, mock26from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
29from friends.utils.model import COLUMN_TYPES27from friends.tests.mocks import TestModel, mock
30from friends.errors import AuthorizationError28from friends.errors import AuthorizationError
3129
3230
33# Create a test model that will not interfere with the user's environment.
34# We'll use this object as a mock of the real model.
35TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
36TestModel.set_schema_full(COLUMN_TYPES)
37
38
39@mock.patch('friends.utils.http._soup', mock.Mock())31@mock.patch('friends.utils.http._soup', mock.Mock())
40@mock.patch('friends.utils.base.notify', mock.Mock())32@mock.patch('friends.utils.base.notify', mock.Mock())
41class TestFourSquare(unittest.TestCase):33class TestFourSquare(unittest.TestCase):
@@ -93,11 +85,11 @@
93 self.assertEqual(self.protocol.receive(), 1)85 self.assertEqual(self.protocol.receive(), 1)
94 self.assertEqual(1, TestModel.get_n_rows())86 self.assertEqual(1, TestModel.get_n_rows())
95 expected = [87 expected = [
96 [['foursquare', '1234', '50574c9ce4b0a9a6e84433a0']],88 'foursquare', 88, '50574c9ce4b0a9a6e84433a0',
97 'messages', 'Jimbob Smith', '', '', True, '2012-09-17T19:15:24Z',89 'messages', 'Jimbob Smith', '', '', True, '2012-09-17T19:15:24Z',
98 "Working on friends's foursquare plugin.",90 "Working on friends's foursquare plugin.",
99 '~/.cache/friends/avatar/hash', '', 0.0, False, '', '', '',91 '~/.cache/friends/avatar/hash', '', 0, False, '', '', '',
100 '', '', '',92 '', '', '', 'Pop Soda\'s Coffee House & Gallery',
93 49.88873164336725, -97.158043384552,
101 ]94 ]
102 for got, want in zip(TestModel.get_row(0), expected):95 self.assertEqual(list(TestModel.get_row(0)), expected)
103 self.assertEqual(got, want)
10496
=== modified file 'friends/tests/test_identica.py'
--- friends/tests/test_identica.py 2013-03-08 02:31:15 +0000
+++ friends/tests/test_identica.py 2013-03-20 13:27:53 +0000
@@ -26,23 +26,15 @@
26import unittest26import unittest
27import shutil27import shutil
2828
29from gi.repository import Dee
30
31from friends.protocols.identica import Identica29from friends.protocols.identica import Identica
32from friends.tests.mocks import FakeAccount, LogMock, mock30from friends.tests.mocks import FakeAccount, LogMock, TestModel, mock
33from friends.utils.cache import JsonCache31from friends.utils.cache import JsonCache
34from friends.utils.model import COLUMN_TYPES
35from friends.errors import AuthorizationError32from friends.errors import AuthorizationError
3633
3734
38# Create a test model that will not interfere with the user's environment.
39# We'll use this object as a mock of the real model.
40TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
41TestModel.set_schema_full(COLUMN_TYPES)
42
43
44@mock.patch('friends.utils.http._soup', mock.Mock())35@mock.patch('friends.utils.http._soup', mock.Mock())
45@mock.patch('friends.utils.base.notify', mock.Mock())36@mock.patch('friends.utils.base.notify', mock.Mock())
37@mock.patch('friends.utils.base.Model', TestModel)
46class TestIdentica(unittest.TestCase):38class TestIdentica(unittest.TestCase):
47 """Test the Identica API."""39 """Test the Identica API."""
4840
4941
=== modified file 'friends/tests/test_mock_dispatcher.py'
--- friends/tests/test_mock_dispatcher.py 2013-02-05 01:11:35 +0000
+++ friends/tests/test_mock_dispatcher.py 2013-03-20 13:27:53 +0000
@@ -22,13 +22,11 @@
2222
23import dbus.service23import dbus.service
24import unittest24import unittest
25import json
2625
27from dbus.mainloop.glib import DBusGMainLoop26from dbus.mainloop.glib import DBusGMainLoop
2827
29from friends.service.mock_service import Dispatcher as MockDispatcher28from friends.service.mock_service import Dispatcher as MockDispatcher
30from friends.service.dispatcher import Dispatcher29from friends.service.dispatcher import Dispatcher
31from friends.tests.mocks import LogMock, mock
3230
3331
34# Set up the DBus main loop.32# Set up the DBus main loop.
3533
=== modified file 'friends/tests/test_model.py'
--- friends/tests/test_model.py 2013-02-05 01:11:35 +0000
+++ friends/tests/test_model.py 2013-03-20 13:27:53 +0000
@@ -26,10 +26,8 @@
2626
27import unittest27import unittest
2828
29from friends.utils.model import Model
30from friends.utils.model import prune_model, persist_model29from friends.utils.model import prune_model, persist_model
31from friends.tests.mocks import LogMock, mock30from friends.tests.mocks import LogMock, mock
32from gi.repository import Dee
3331
3432
35class TestModel(unittest.TestCase):33class TestModel(unittest.TestCase):
@@ -42,6 +40,17 @@
42 self.log_mock.stop()40 self.log_mock.stop()
4341
44 @mock.patch('friends.utils.model.Model')42 @mock.patch('friends.utils.model.Model')
43 def test_persist_model(self, model):
44 model.__len__.return_value = 500
45 model.is_synchronized.return_value = True
46 persist_model()
47 model.is_synchronized.assert_called_once_with()
48 model.flush_revision_queue.assert_called_once_with()
49 self.assertEqual(self.log_mock.empty(),
50 'Trying to save Dee.SharedModel with 500 rows.\n' +
51 'Saving Dee.SharedModel with 500 rows.\n')
52
53 @mock.patch('friends.utils.model.Model')
45 @mock.patch('friends.utils.model.persist_model')54 @mock.patch('friends.utils.model.persist_model')
46 def test_prune_one(self, persist, model):55 def test_prune_one(self, persist, model):
47 model.get_n_rows.return_value = 800156 model.get_n_rows.return_value = 8001
4857
=== modified file 'friends/tests/test_notify.py'
--- friends/tests/test_notify.py 2013-02-05 01:11:35 +0000
+++ friends/tests/test_notify.py 2013-03-20 13:27:53 +0000
@@ -22,20 +22,11 @@
2222
23import unittest23import unittest
2424
25from gi.repository import Dee25from friends.tests.mocks import FakeAccount, TestModel, mock
26
27from friends.tests.mocks import FakeAccount, mock
28from friends.utils.base import Base26from friends.utils.base import Base
29from friends.utils.model import COLUMN_TYPES
30from friends.utils.notify import notify27from friends.utils.notify import notify
3128
3229
33# Create a test model that will not interfere with the user's environment.
34# We'll use this object as a mock of the real model.
35TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
36TestModel.set_schema_full(COLUMN_TYPES)
37
38
39class TestNotifications(unittest.TestCase):30class TestNotifications(unittest.TestCase):
40 """Test notification details."""31 """Test notification details."""
4132
@@ -43,7 +34,6 @@
43 TestModel.clear()34 TestModel.clear()
4435
45 @mock.patch('friends.utils.base.Model', TestModel)36 @mock.patch('friends.utils.base.Model', TestModel)
46 @mock.patch('friends.utils.base._seen_messages', {})
47 @mock.patch('friends.utils.base._seen_ids', {})37 @mock.patch('friends.utils.base._seen_ids', {})
48 @mock.patch('friends.utils.base.notify')38 @mock.patch('friends.utils.base.notify')
49 def test_publish_all(self, notify):39 def test_publish_all(self, notify):
@@ -57,7 +47,6 @@
57 notify.assert_called_once_with('Benjamin', 'notify!', '')47 notify.assert_called_once_with('Benjamin', 'notify!', '')
5848
59 @mock.patch('friends.utils.base.Model', TestModel)49 @mock.patch('friends.utils.base.Model', TestModel)
60 @mock.patch('friends.utils.base._seen_messages', {})
61 @mock.patch('friends.utils.base._seen_ids', {})50 @mock.patch('friends.utils.base._seen_ids', {})
62 @mock.patch('friends.utils.base.notify')51 @mock.patch('friends.utils.base.notify')
63 def test_publish_mentions_private(self, notify):52 def test_publish_mentions_private(self, notify):
@@ -73,7 +62,6 @@
73 notify.assert_called_once_with('Benjamin', 'This message is private!', '')62 notify.assert_called_once_with('Benjamin', 'This message is private!', '')
7463
75 @mock.patch('friends.utils.base.Model', TestModel)64 @mock.patch('friends.utils.base.Model', TestModel)
76 @mock.patch('friends.utils.base._seen_messages', {})
77 @mock.patch('friends.utils.base._seen_ids', {})65 @mock.patch('friends.utils.base._seen_ids', {})
78 @mock.patch('friends.utils.base.notify')66 @mock.patch('friends.utils.base.notify')
79 def test_publish_mention_fail(self, notify):67 def test_publish_mention_fail(self, notify):
@@ -89,7 +77,6 @@
89 self.assertEqual(notify.call_count, 0)77 self.assertEqual(notify.call_count, 0)
9078
91 @mock.patch('friends.utils.base.Model', TestModel)79 @mock.patch('friends.utils.base.Model', TestModel)
92 @mock.patch('friends.utils.base._seen_messages', {})
93 @mock.patch('friends.utils.base._seen_ids', {})80 @mock.patch('friends.utils.base._seen_ids', {})
94 @mock.patch('friends.utils.base.notify')81 @mock.patch('friends.utils.base.notify')
95 def test_publish_mention_none(self, notify):82 def test_publish_mention_none(self, notify):
9683
=== modified file 'friends/tests/test_protocols.py'
--- friends/tests/test_protocols.py 2013-02-05 01:11:35 +0000
+++ friends/tests/test_protocols.py 2013-03-20 13:27:53 +0000
@@ -24,21 +24,12 @@
24import unittest24import unittest
25import threading25import threading
2626
27from gi.repository import Dee
28
29from friends.protocols.flickr import Flickr27from friends.protocols.flickr import Flickr
30from friends.protocols.twitter import Twitter28from friends.protocols.twitter import Twitter
31from friends.tests.mocks import FakeAccount, LogMock, mock29from friends.tests.mocks import FakeAccount, LogMock, TestModel, mock
32from friends.utils.base import Base, feature30from friends.utils.base import Base, feature, linkify_string
33from friends.utils.manager import ProtocolManager31from friends.utils.manager import ProtocolManager
34from friends.utils.model import (32from friends.utils.model import COLUMN_INDICES, Model
35 COLUMN_INDICES, COLUMN_NAMES, COLUMN_TYPES, Model)
36
37
38# Create a test model that will not interfere with the user's environment.
39# We'll use this object as a mock of the real model.
40TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
41TestModel.set_schema_full(COLUMN_TYPES)
4233
4334
44class TestProtocolManager(unittest.TestCase):35class TestProtocolManager(unittest.TestCase):
@@ -147,42 +138,39 @@
147 count = Model.get_n_rows()138 count = Model.get_n_rows()
148 self.assertEqual(TestModel.get_n_rows(), 0)139 self.assertEqual(TestModel.get_n_rows(), 0)
149 base = Base(FakeAccount())140 base = Base(FakeAccount())
150 base._publish('alpha', message='a')141 base._publish(message_id='alpha', message='a')
151 base._publish('beta', message='b')142 base._publish(message_id='beta', message='b')
152 base._publish('omega', message='c')143 base._publish(message_id='omega', message='c')
153 self.assertEqual(Model.get_n_rows(), count)144 self.assertEqual(Model.get_n_rows(), count)
154 self.assertEqual(TestModel.get_n_rows(), 3)145 self.assertEqual(TestModel.get_n_rows(), 3)
155146
156 @mock.patch('friends.utils.base.Model', TestModel)147 @mock.patch('friends.utils.base.Model', TestModel)
157 @mock.patch('friends.utils.base._seen_ids', {})148 @mock.patch('friends.utils.base._seen_ids', {})
158 @mock.patch('friends.utils.base._seen_messages', {})
159 def test_seen_dicts_successfully_instantiated(self):149 def test_seen_dicts_successfully_instantiated(self):
160 from friends.utils.base import _seen_ids, _seen_messages150 from friends.utils.base import _seen_ids
161 from friends.utils.base import initialize_caches151 from friends.utils.base import initialize_caches
162 self.assertEqual(TestModel.get_n_rows(), 0)152 self.assertEqual(TestModel.get_n_rows(), 0)
163 base = Base(FakeAccount())153 base = Base(FakeAccount())
164 base._publish('alpha', sender='a', message='a')154 base._publish(message_id='alpha', sender='a', message='a')
165 base._publish('beta', sender='a', message='a')155 base._publish(message_id='beta', sender='a', message='a')
166 base._publish('omega', sender='a', message='b')156 base._publish(message_id='omega', sender='a', message='b')
167 self.assertEqual(TestModel.get_n_rows(), 2)157 self.assertEqual(TestModel.get_n_rows(), 3)
168 _seen_ids.clear()158 _seen_ids.clear()
169 _seen_messages.clear()
170 initialize_caches()159 initialize_caches()
171 self.assertEqual(sorted(list(_seen_messages.keys())), ['aa', 'ab'])160 self.assertEqual(
172 self.assertEqual(sorted(list(_seen_ids.keys())),161 _seen_ids,
173 [('base', '1234', 'alpha'),162 dict(alpha=0,
174 ('base', '1234', 'beta'),163 beta=1,
175 ('base', '1234', 'omega')])164 omega=2,
176 # These two point at the same row because sender+message are identical165 )
177 self.assertEqual(_seen_ids[('base', '1234', 'alpha')],166 )
178 _seen_ids[('base', '1234', 'beta')])
179167
180 @mock.patch('friends.utils.base.Model', TestModel)168 @mock.patch('friends.utils.base.Model', TestModel)
181 def test_invalid_argument(self):169 def test_invalid_argument(self):
182 base = Base(FakeAccount())170 base = Base(FakeAccount())
183 self.assertEqual(0, TestModel.get_n_rows())171 self.assertEqual(0, TestModel.get_n_rows())
184 with self.assertRaises(TypeError) as cm:172 with self.assertRaises(TypeError) as cm:
185 base._publish('message_id', invalid_argument='not good')173 base._publish(message_id='message_id', invalid_argument='not good')
186 self.assertEqual(str(cm.exception),174 self.assertEqual(str(cm.exception),
187 'Unexpected keyword arguments: invalid_argument')175 'Unexpected keyword arguments: invalid_argument')
188176
@@ -192,12 +180,11 @@
192 base = Base(FakeAccount())180 base = Base(FakeAccount())
193 self.assertEqual(0, TestModel.get_n_rows())181 self.assertEqual(0, TestModel.get_n_rows())
194 with self.assertRaises(TypeError) as cm:182 with self.assertRaises(TypeError) as cm:
195 base._publish('p.middy', bad='no', wrong='yes')183 base._publish(message_id='p.middy', bad='no', wrong='yes')
196 self.assertEqual(str(cm.exception),184 self.assertEqual(str(cm.exception),
197 'Unexpected keyword arguments: bad, wrong')185 'Unexpected keyword arguments: bad, wrong')
198186
199 @mock.patch('friends.utils.base.Model', TestModel)187 @mock.patch('friends.utils.base.Model', TestModel)
200 @mock.patch('friends.utils.base._seen_messages', {})
201 @mock.patch('friends.utils.base._seen_ids', {})188 @mock.patch('friends.utils.base._seen_ids', {})
202 def test_one_message(self):189 def test_one_message(self):
203 # Test that publishing a message inserts a row into the model.190 # Test that publishing a message inserts a row into the model.
@@ -215,28 +202,34 @@
215 liked=True))202 liked=True))
216 self.assertEqual(1, TestModel.get_n_rows())203 self.assertEqual(1, TestModel.get_n_rows())
217 row = TestModel.get_row(0)204 row = TestModel.get_row(0)
218 # For convenience.205 self.assertEqual(
219 def V(column_name):206 list(row),
220 return row[COLUMN_INDICES[column_name]]207 ['base',
221 self.assertEqual(V('message_ids'),208 88,
222 [['base', '1234', '1234']])209 '1234',
223 self.assertEqual(V('stream'), 'messages')210 'messages',
224 self.assertEqual(V('sender'), 'fred')211 'fred',
225 self.assertEqual(V('sender_nick'), 'freddy')212 '',
226 self.assertTrue(V('from_me'))213 'freddy',
227 self.assertEqual(V('timestamp'), 'today')214 True,
228 self.assertEqual(V('message'), 'hello, @jimmy')215 'today',
229 self.assertEqual(V('likes'), 10)216 'hello, @jimmy',
230 self.assertTrue(V('liked'))217 '',
231 # All the other columns have empty string values.218 '',
232 empty_columns = set(COLUMN_NAMES) - set(219 10,
233 ['message_ids', 'stream', 'sender', 'sender_nick', 'from_me',220 True,
234 'timestamp', 'comments', 'message', 'likes', 'liked'])221 '',
235 for column_name in empty_columns:222 '',
236 self.assertEqual(row[COLUMN_INDICES[column_name]], '')223 '',
224 '',
225 '',
226 '',
227 '',
228 0.0,
229 0.0,
230 ])
237231
238 @mock.patch('friends.utils.base.Model', TestModel)232 @mock.patch('friends.utils.base.Model', TestModel)
239 @mock.patch('friends.utils.base._seen_messages', {})
240 @mock.patch('friends.utils.base._seen_ids', {})233 @mock.patch('friends.utils.base._seen_ids', {})
241 def test_unpublish(self):234 def test_unpublish(self):
242 base = Base(FakeAccount())235 base = Base(FakeAccount())
@@ -246,32 +239,24 @@
246 sender='fred',239 sender='fred',
247 message='hello, @jimmy'))240 message='hello, @jimmy'))
248 self.assertTrue(base._publish(241 self.assertTrue(base._publish(
242 message_id='1234',
243 sender='fred',
244 message='hello, @jimmy'))
245 self.assertTrue(base._publish(
249 message_id='5678',246 message_id='5678',
250 sender='fred',247 sender='fred',
251 message='hello, +jimmy'))248 message='hello, +jimmy'))
252 self.assertEqual(1, TestModel.get_n_rows())249 self.assertEqual(2, TestModel.get_n_rows())
253 self.assertEqual(TestModel[0][0],
254 [['base', '1234', '1234'],
255 ['base', '1234', '5678']])
256 base._unpublish('1234')250 base._unpublish('1234')
257 self.assertEqual(1, TestModel.get_n_rows())251 self.assertEqual(1, TestModel.get_n_rows())
258 self.assertEqual(TestModel[0][0],
259 [['base', '1234', '5678']])
260 base._unpublish('5678')252 base._unpublish('5678')
261 self.assertEqual(0, TestModel.get_n_rows())253 self.assertEqual(0, TestModel.get_n_rows())
262254
263 @mock.patch('friends.utils.base.Model', TestModel)255 @mock.patch('friends.utils.base.Model', TestModel)
264 @mock.patch('friends.utils.base._seen_messages', {})
265 @mock.patch('friends.utils.base._seen_ids', {})256 @mock.patch('friends.utils.base._seen_ids', {})
266 def test_duplicate_messages_identified(self):257 def test_duplicate_messages_identified(self):
267 # When two messages which are deemed identical, by way of the
268 # _make_key() test in base.py, are published, only one ends up in the
269 # model. However, the message_ids list-of-lists gets both sets of
270 # identifiers.
271 base = Base(FakeAccount())258 base = Base(FakeAccount())
272 self.assertEqual(0, TestModel.get_n_rows())259 self.assertEqual(0, TestModel.get_n_rows())
273 # Insert the first message into the table. The key will be the string
274 # 'fredhellojimmy'
275 self.assertTrue(base._publish(260 self.assertTrue(base._publish(
276 message_id='1234',261 message_id='1234',
277 stream='messages',262 stream='messages',
@@ -282,11 +267,9 @@
282 message='hello, @jimmy',267 message='hello, @jimmy',
283 likes=10,268 likes=10,
284 liked=True))269 liked=True))
285 # Insert the second message into the table. Note that because270 # Duplicate
286 # punctuation was stripped from the above message, this one will also
287 # have the key 'fredhellojimmy', thus it will be deemed a duplicate.
288 self.assertTrue(base._publish(271 self.assertTrue(base._publish(
289 message_id='5678',272 message_id='1234',
290 stream='messages',273 stream='messages',
291 sender='fred',274 sender='fred',
292 sender_nick='freddy',275 sender_nick='freddy',
@@ -300,13 +283,8 @@
300 # The first published message wins.283 # The first published message wins.
301 row = TestModel.get_row(0)284 row = TestModel.get_row(0)
302 self.assertEqual(row[COLUMN_INDICES['message']], 'hello, @jimmy')285 self.assertEqual(row[COLUMN_INDICES['message']], 'hello, @jimmy')
303 # Both message ids will be present, in the order they were published.
304 self.assertEqual(row[COLUMN_INDICES['message_ids']],
305 [['base', '1234', '1234'],
306 ['base', '1234', '5678']])
307286
308 @mock.patch('friends.utils.base.Model', TestModel)287 @mock.patch('friends.utils.base.Model', TestModel)
309 @mock.patch('friends.utils.base._seen_messages', {})
310 @mock.patch('friends.utils.base._seen_ids', {})288 @mock.patch('friends.utils.base._seen_ids', {})
311 def test_duplicate_ids_not_duplicated(self):289 def test_duplicate_ids_not_duplicated(self):
312 # When two messages are actually identical (same ids and all),290 # When two messages are actually identical (same ids and all),
@@ -325,12 +303,34 @@
325 message='hello, @jimmy'))303 message='hello, @jimmy'))
326 self.assertEqual(1, TestModel.get_n_rows())304 self.assertEqual(1, TestModel.get_n_rows())
327 row = TestModel.get_row(0)305 row = TestModel.get_row(0)
328 # The same message_id should not appear twice.306 self.assertEqual(
329 self.assertEqual(row[COLUMN_INDICES['message_ids']],307 list(row),
330 [['base', '1234', '1234']])308 ['base',
309 88,
310 '1234',
311 'messages',
312 'fred',
313 '',
314 '',
315 False,
316 '',
317 'hello, @jimmy',
318 '',
319 '',
320 0,
321 False,
322 '',
323 '',
324 '',
325 '',
326 '',
327 '',
328 '',
329 0.0,
330 0.0,
331 ])
331332
332 @mock.patch('friends.utils.base.Model', TestModel)333 @mock.patch('friends.utils.base.Model', TestModel)
333 @mock.patch('friends.utils.base._seen_messages', {})
334 @mock.patch('friends.utils.base._seen_ids', {})334 @mock.patch('friends.utils.base._seen_ids', {})
335 def test_similar_messages_allowed(self):335 def test_similar_messages_allowed(self):
336 # Because both the sender and message contribute to the unique key we336 # Because both the sender and message contribute to the unique key we
@@ -392,3 +392,78 @@
392392
393 def test_features(self):393 def test_features(self):
394 self.assertEqual(MyProtocol.get_features(), ['feature_1', 'feature_2'])394 self.assertEqual(MyProtocol.get_features(), ['feature_1', 'feature_2'])
395
396 def test_linkify_string(self):
397 # String with no URL is unchanged.
398 self.assertEqual('Hello!', linkify_string('Hello!'))
399 # http:// works.
400 self.assertEqual(
401 '<a href="http://www.example.com">http://www.example.com</a>',
402 linkify_string('http://www.example.com'))
403 # https:// works, too.
404 self.assertEqual(
405 '<a href="https://www.example.com">https://www.example.com</a>',
406 linkify_string('https://www.example.com'))
407 # http:// is optional if you include www.
408 self.assertEqual(
409 '<a href="www.example.com">www.example.com</a>',
410 linkify_string('www.example.com'))
411 # Haha, nobody uses ftp anymore!
412 self.assertEqual(
413 '<a href="ftp://example.com/">ftp://example.com/</a>',
414 linkify_string('ftp://example.com/'))
415 # Trailing periods are not linkified.
416 self.assertEqual(
417 '<a href="http://example.com">http://example.com</a>.',
418 linkify_string('http://example.com.'))
419 # URL can contain periods without getting cut off.
420 self.assertEqual(
421 '<a href="http://example.com/products/buy.html">'
422 'http://example.com/products/buy.html</a>.',
423 linkify_string('http://example.com/products/buy.html.'))
424 # Don't linkify trailing brackets.
425 self.assertEqual(
426 'Example Co (<a href="http://example.com">http://example.com</a>).',
427 linkify_string('Example Co (http://example.com).'))
428 # Don't linkify trailing exclamation marks.
429 self.assertEqual(
430 'Go to <a href="https://example.com">https://example.com</a>!',
431 linkify_string('Go to https://example.com!'))
432 # Don't linkify trailing commas, also ensure all links are found.
433 self.assertEqual(
434 '<a href="www.example.com">www.example.com</a>, <a '
435 'href="http://example.com/stuff">http://example.com/stuff</a>, and '
436 '<a href="http://example.com/things">http://example.com/things</a> '
437 'are my favorite sites.',
438 linkify_string('www.example.com, http://example.com/stuff, and '
439 'http://example.com/things are my favorite sites.'))
440 # Don't linkify trailing question marks.
441 self.assertEqual(
442 'Ever been to <a href="www.example.com">www.example.com</a>?',
443 linkify_string('Ever been to www.example.com?'))
444 # URLs can contain question marks ok.
445 self.assertEqual(
446 'Like <a href="http://example.com?foo=bar&grill=true">'
447 'http://example.com?foo=bar&grill=true</a>?',
448 linkify_string('Like http://example.com?foo=bar&grill=true?'))
449 # Multi-line strings are also supported.
450 self.assertEqual(
451 'Hey, visit us online!\n\n'
452 '<a href="http://example.com">http://example.com</a>',
453 linkify_string('Hey, visit us online!\n\nhttp://example.com'))
454 # Don't accidentally duplicate linkification.
455 self.assertEqual(
456 '<a href="www.example.com">click here!</a>',
457 linkify_string('<a href="www.example.com">click here!</a>'))
458 self.assertEqual(
459 '<a href="www.example.com">www.example.com</a>',
460 linkify_string('<a href="www.example.com">www.example.com</a>'))
461 self.assertEqual(
462 '<a href="www.example.com">www.example.com</a> is our website',
463 linkify_string(
464 '<a href="www.example.com">www.example.com</a> is our website'))
465 # This, apparently, is valid HTML.
466 self.assertEqual(
467 '<a href = "www.example.com">www.example.com</a>',
468 linkify_string(
469 '<a href = "www.example.com">www.example.com</a>'))
395470
=== modified file 'friends/tests/test_shortener.py'
--- friends/tests/test_shortener.py 2013-02-13 02:05:37 +0000
+++ friends/tests/test_shortener.py 2013-03-20 13:27:53 +0000
@@ -22,8 +22,6 @@
2222
23import unittest23import unittest
2424
25from operator import getitem
26
27from friends.shorteners import isgd, ougd, linkeecom, lookup, tinyurlcom25from friends.shorteners import isgd, ougd, linkeecom, lookup, tinyurlcom
28from friends.tests.mocks import FakeSoupMessage, mock26from friends.tests.mocks import FakeSoupMessage, mock
2927
3028
=== modified file 'friends/tests/test_twitter.py'
--- friends/tests/test_twitter.py 2013-03-08 02:31:15 +0000
+++ friends/tests/test_twitter.py 2013-03-20 13:27:53 +0000
@@ -26,22 +26,16 @@
26import unittest26import unittest
27import shutil27import shutil
2828
29from gi.repository import GLib, Dee29from gi.repository import GLib
30from urllib.error import HTTPError30from urllib.error import HTTPError
3131
32from friends.protocols.twitter import RateLimiter, Twitter32from friends.protocols.twitter import RateLimiter, Twitter
33from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock, mock33from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
34from friends.tests.mocks import TestModel, mock
34from friends.utils.cache import JsonCache35from friends.utils.cache import JsonCache
35from friends.utils.model import COLUMN_TYPES
36from friends.errors import AuthorizationError36from friends.errors import AuthorizationError
3737
3838
39# Create a test model that will not interfere with the user's environment.
40# We'll use this object as a mock of the real model.
41TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel')
42TestModel.set_schema_full(COLUMN_TYPES)
43
44
45@mock.patch('friends.utils.http._soup', mock.Mock())39@mock.patch('friends.utils.http._soup', mock.Mock())
46@mock.patch('friends.utils.base.notify', mock.Mock())40@mock.patch('friends.utils.base.notify', mock.Mock())
47class TestTwitter(unittest.TestCase):41class TestTwitter(unittest.TestCase):
@@ -124,7 +118,6 @@
124 FakeSoupMessage('friends.tests.data', 'twitter-home.dat'))118 FakeSoupMessage('friends.tests.data', 'twitter-home.dat'))
125 @mock.patch('friends.protocols.twitter.Twitter._login',119 @mock.patch('friends.protocols.twitter.Twitter._login',
126 return_value=True)120 return_value=True)
127 @mock.patch('friends.utils.base._seen_messages', {})
128 @mock.patch('friends.utils.base._seen_ids', {})121 @mock.patch('friends.utils.base._seen_ids', {})
129 def test_home(self, *mocks):122 def test_home(self, *mocks):
130 self.account.access_token = 'access'123 self.account.access_token = 'access'
@@ -138,31 +131,32 @@
138131
139 # This test data was ripped directly from Twitter's API docs.132 # This test data was ripped directly from Twitter's API docs.
140 expected = [133 expected = [
141 [[['twitter', '1234', '240558470661799936']],134 ['twitter', 88, '240558470661799936',
142 'messages', 'OAuth Dancer', '119476949', 'oauth_dancer', False,135 'messages', 'OAuth Dancer', '119476949', 'oauth_dancer', False,
143 '2012-08-28T21:16:23Z', 'just another test',136 '2012-08-28T21:16:23Z', 'just another test',
144 GLib.get_user_cache_dir() +137 GLib.get_user_cache_dir() +
145 '/friends/avatars/ded4ba3c00583ee511f399d0b2537731ca14c39d',138 '/friends/avatars/ded4ba3c00583ee511f399d0b2537731ca14c39d',
146 'https://twitter.com/oauth_dancer/status/240558470661799936',139 'https://twitter.com/oauth_dancer/status/240558470661799936',
147 0.0, False, '', '', '', '', '', '',140 0, False, '', '', '', '', '', '', '', 0.0, 0.0,
148 ],141 ],
149 [[['twitter', '1234', '240556426106372096']],142 ['twitter', 88, '240556426106372096',
150 'messages', 'Raffi Krikorian', '8285392', 'raffi', False,143 'messages', 'Raffi Krikorian', '8285392', 'raffi', False,
151 '2012-08-28T21:08:15Z', 'lecturing at the "analyzing big data ' +144 '2012-08-28T21:08:15Z', 'lecturing at the "analyzing big data '
152 'with twitter" class at @cal with @othman http://t.co/bfj7zkDJ',145 'with twitter" class at @cal with @othman '
146 '<a href="http://t.co/bfj7zkDJ">http://t.co/bfj7zkDJ</a>',
153 GLib.get_user_cache_dir() +147 GLib.get_user_cache_dir() +
154 '/friends/avatars/0219effc03a3049a622476e6e001a4014f33dc31',148 '/friends/avatars/0219effc03a3049a622476e6e001a4014f33dc31',
155 'https://twitter.com/raffi/status/240556426106372096',149 'https://twitter.com/raffi/status/240556426106372096',
156 0.0, False, '', '', '', '', '', '',150 0, False, '', '', '', '', '', '', '', 0.0, 0.0,
157 ],151 ],
158 [[['twitter', '1234', '240539141056638977']],152 ['twitter', 88, '240539141056638977',
159 'messages', 'Taylor Singletary', '819797', 'episod', False,153 'messages', 'Taylor Singletary', '819797', 'episod', False,
160 '2012-08-28T19:59:34Z',154 '2012-08-28T19:59:34Z',
161 'You\'d be right more often if you thought you were wrong.',155 'You\'d be right more often if you thought you were wrong.',
162 GLib.get_user_cache_dir() +156 GLib.get_user_cache_dir() +
163 '/friends/avatars/0c829cb2934ad76489be21ee5e103735d9b7b034',157 '/friends/avatars/0c829cb2934ad76489be21ee5e103735d9b7b034',
164 'https://twitter.com/episod/status/240539141056638977',158 'https://twitter.com/episod/status/240539141056638977',
165 0.0, False, '', '', '', '', '', '',159 0, False, '', '', '', '', '', '', '', 0.0, 0.0,
166 ],160 ],
167 ]161 ]
168 for i, expected_row in enumerate(expected):162 for i, expected_row in enumerate(expected):
@@ -173,7 +167,6 @@
173 FakeSoupMessage('friends.tests.data', 'twitter-home.dat'))167 FakeSoupMessage('friends.tests.data', 'twitter-home.dat'))
174 @mock.patch('friends.protocols.twitter.Twitter._login',168 @mock.patch('friends.protocols.twitter.Twitter._login',
175 return_value=True)169 return_value=True)
176 @mock.patch('friends.utils.base._seen_messages', {})
177 @mock.patch('friends.utils.base._seen_ids', {})170 @mock.patch('friends.utils.base._seen_ids', {})
178 def test_home_since_id(self, *mocks):171 def test_home_since_id(self, *mocks):
179 self.account.access_token = 'access'172 self.account.access_token = 'access'
@@ -193,13 +186,11 @@
193 'https://api.twitter.com/1.1/statuses/' +186 'https://api.twitter.com/1.1/statuses/' +
194 'home_timeline.json?count=50&since_id=240558470661799936')187 'home_timeline.json?count=50&since_id=240558470661799936')
195188
196
197 @mock.patch('friends.utils.base.Model', TestModel)189 @mock.patch('friends.utils.base.Model', TestModel)
198 @mock.patch('friends.utils.http.Soup.Message',190 @mock.patch('friends.utils.http.Soup.Message',
199 FakeSoupMessage('friends.tests.data', 'twitter-send.dat'))191 FakeSoupMessage('friends.tests.data', 'twitter-send.dat'))
200 @mock.patch('friends.protocols.twitter.Twitter._login',192 @mock.patch('friends.protocols.twitter.Twitter._login',
201 return_value=True)193 return_value=True)
202 @mock.patch('friends.utils.base._seen_messages', {})
203 @mock.patch('friends.utils.base._seen_ids', {})194 @mock.patch('friends.utils.base._seen_ids', {})
204 def test_from_me(self, *mocks):195 def test_from_me(self, *mocks):
205 self.account.access_token = 'access'196 self.account.access_token = 'access'
@@ -216,18 +207,17 @@
216207
217 # This test data was ripped directly from Twitter's API docs.208 # This test data was ripped directly from Twitter's API docs.
218 expected_row = [209 expected_row = [
219 [['twitter', '1234', '240558470661799936']],210 'twitter', 88, '240558470661799936',
220 'messages', 'OAuth Dancer', '119476949', 'oauth_dancer', True,211 'messages', 'OAuth Dancer', '119476949', 'oauth_dancer', True,
221 '2012-08-28T21:16:23Z', 'just another test',212 '2012-08-28T21:16:23Z', 'just another test',
222 GLib.get_user_cache_dir() +213 GLib.get_user_cache_dir() +
223 '/friends/avatars/ded4ba3c00583ee511f399d0b2537731ca14c39d',214 '/friends/avatars/ded4ba3c00583ee511f399d0b2537731ca14c39d',
224 'https://twitter.com/oauth_dancer/status/240558470661799936',215 'https://twitter.com/oauth_dancer/status/240558470661799936',
225 0.0, False, '', '', '', '', '', '',216 0, False, '', '', '', '', '', '', '', 0.0, 0.0,
226 ]217 ]
227 self.assertEqual(list(TestModel.get_row(0)), expected_row)218 self.assertEqual(list(TestModel.get_row(0)), expected_row)
228219
229 @mock.patch('friends.utils.base.Model', TestModel)220 @mock.patch('friends.utils.base.Model', TestModel)
230 @mock.patch('friends.utils.base._seen_messages', {})
231 @mock.patch('friends.utils.base._seen_ids', {})221 @mock.patch('friends.utils.base._seen_ids', {})
232 def test_home_url(self):222 def test_home_url(self):
233 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])223 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])
@@ -240,7 +230,6 @@
240 'https://api.twitter.com/1.1/statuses/home_timeline.json?count=50')230 'https://api.twitter.com/1.1/statuses/home_timeline.json?count=50')
241231
242 @mock.patch('friends.utils.base.Model', TestModel)232 @mock.patch('friends.utils.base.Model', TestModel)
243 @mock.patch('friends.utils.base._seen_messages', {})
244 @mock.patch('friends.utils.base._seen_ids', {})233 @mock.patch('friends.utils.base._seen_ids', {})
245 def test_mentions(self):234 def test_mentions(self):
246 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])235 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])
@@ -254,7 +243,6 @@
254 'mentions_timeline.json?count=50')243 'mentions_timeline.json?count=50')
255244
256 @mock.patch('friends.utils.base.Model', TestModel)245 @mock.patch('friends.utils.base.Model', TestModel)
257 @mock.patch('friends.utils.base._seen_messages', {})
258 @mock.patch('friends.utils.base._seen_ids', {})246 @mock.patch('friends.utils.base._seen_ids', {})
259 def test_user(self):247 def test_user(self):
260 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])248 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])
@@ -267,7 +255,6 @@
267 'https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=')255 'https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=')
268256
269 @mock.patch('friends.utils.base.Model', TestModel)257 @mock.patch('friends.utils.base.Model', TestModel)
270 @mock.patch('friends.utils.base._seen_messages', {})
271 @mock.patch('friends.utils.base._seen_ids', {})258 @mock.patch('friends.utils.base._seen_ids', {})
272 def test_list(self):259 def test_list(self):
273 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])260 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])
@@ -280,7 +267,6 @@
280 'https://api.twitter.com/1.1/lists/statuses.json?list_id=some_list_id')267 'https://api.twitter.com/1.1/lists/statuses.json?list_id=some_list_id')
281268
282 @mock.patch('friends.utils.base.Model', TestModel)269 @mock.patch('friends.utils.base.Model', TestModel)
283 @mock.patch('friends.utils.base._seen_messages', {})
284 @mock.patch('friends.utils.base._seen_ids', {})270 @mock.patch('friends.utils.base._seen_ids', {})
285 def test_lists(self):271 def test_lists(self):
286 get_url = self.protocol._get_url = mock.Mock(272 get_url = self.protocol._get_url = mock.Mock(
@@ -294,7 +280,6 @@
294 'https://api.twitter.com/1.1/lists/list.json')280 'https://api.twitter.com/1.1/lists/list.json')
295281
296 @mock.patch('friends.utils.base.Model', TestModel)282 @mock.patch('friends.utils.base.Model', TestModel)
297 @mock.patch('friends.utils.base._seen_messages', {})
298 @mock.patch('friends.utils.base._seen_ids', {})283 @mock.patch('friends.utils.base._seen_ids', {})
299 def test_private(self):284 def test_private(self):
300 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])285 get_url = self.protocol._get_url = mock.Mock(return_value=['tweet'])
@@ -346,7 +331,6 @@
346 ])331 ])
347332
348 @mock.patch('friends.utils.base.Model', TestModel)333 @mock.patch('friends.utils.base.Model', TestModel)
349 @mock.patch('friends.utils.base._seen_messages', {})
350 @mock.patch('friends.utils.base._seen_ids', {})334 @mock.patch('friends.utils.base._seen_ids', {})
351 def test_send_private(self):335 def test_send_private(self):
352 get_url = self.protocol._get_url = mock.Mock(return_value='tweet')336 get_url = self.protocol._get_url = mock.Mock(return_value='tweet')
@@ -406,6 +390,44 @@
406 'tweet @pumpichank!',390 'tweet @pumpichank!',
407 in_reply_to_status_id='1234'))391 in_reply_to_status_id='1234'))
408392
393 @mock.patch('friends.utils.base.Model', TestModel)
394 @mock.patch('friends.utils.http.Soup.Message',
395 FakeSoupMessage('friends.tests.data', 'twitter-home.dat'))
396 @mock.patch('friends.protocols.twitter.Twitter._login',
397 return_value=True)
398 @mock.patch('friends.utils.base._seen_ids', {})
399 def test_send_thread_prepend_nick(self, *mocks):
400 self.account.access_token = 'access'
401 self.account.secret_token = 'secret'
402 self.account.auth.parameters = dict(
403 ConsumerKey='key',
404 ConsumerSecret='secret')
405 self.assertEqual(0, TestModel.get_n_rows())
406 self.assertEqual(self.protocol.home(), 3)
407 self.assertEqual(3, TestModel.get_n_rows())
408
409 # If you forgot to @mention in your reply, we add it for you.
410 get = self.protocol._get_url = mock.Mock()
411 self.protocol._publish_tweet = mock.Mock()
412 self.protocol.send_thread(
413 '240556426106372096',
414 'Exciting and original response!')
415 get.assert_called_once_with(
416 'https://api.twitter.com/1.1/statuses/update.json',
417 dict(status='@raffi Exciting and original response!',
418 in_reply_to_status_id='240556426106372096'))
419
420 # If you remembered the @mention, we won't duplicate it.
421 get.reset_mock()
422 self.protocol.send_thread(
423 '240556426106372096',
424 'You are the greatest, @raffi!')
425 get.assert_called_once_with(
426 'https://api.twitter.com/1.1/statuses/update.json',
427 dict(status='You are the greatest, @raffi!',
428 in_reply_to_status_id='240556426106372096'))
429
430
409 def test_delete(self):431 def test_delete(self):
410 get_url = self.protocol._get_url = mock.Mock(return_value='tweet')432 get_url = self.protocol._get_url = mock.Mock(return_value='tweet')
411 publish = self.protocol._unpublish = mock.Mock()433 publish = self.protocol._unpublish = mock.Mock()
@@ -466,7 +488,6 @@
466 dict(id='1234'))488 dict(id='1234'))
467489
468 @mock.patch('friends.utils.base.Model', TestModel)490 @mock.patch('friends.utils.base.Model', TestModel)
469 @mock.patch('friends.utils.base._seen_messages', {})
470 @mock.patch('friends.utils.base._seen_ids', {})491 @mock.patch('friends.utils.base._seen_ids', {})
471 def test_tag(self):492 def test_tag(self):
472 get_url = self.protocol._get_url = mock.Mock(493 get_url = self.protocol._get_url = mock.Mock(
@@ -486,7 +507,6 @@
486 'https://api.twitter.com/1.1/search/tweets.json?q=%23yegbike')507 'https://api.twitter.com/1.1/search/tweets.json?q=%23yegbike')
487508
488 @mock.patch('friends.utils.base.Model', TestModel)509 @mock.patch('friends.utils.base.Model', TestModel)
489 @mock.patch('friends.utils.base._seen_messages', {})
490 @mock.patch('friends.utils.base._seen_ids', {})510 @mock.patch('friends.utils.base._seen_ids', {})
491 def test_search(self):511 def test_search(self):
492 get_url = self.protocol._get_url = mock.Mock(512 get_url = self.protocol._get_url = mock.Mock(
493513
=== modified file 'friends/utils/account.py'
--- friends/utils/account.py 2013-02-05 01:11:35 +0000
+++ friends/utils/account.py 2013-03-20 13:27:53 +0000
@@ -43,7 +43,6 @@
43 # are added or deleted.43 # are added or deleted.
44 manager = Accounts.Manager.new_for_service_type('microblogging')44 manager = Accounts.Manager.new_for_service_type('microblogging')
45 manager.connect('enabled-event', self._on_enabled_event)45 manager.connect('enabled-event', self._on_enabled_event)
46 manager.connect('account-deleted', self._on_account_deleted)
47 # Add all the currently known accounts.46 # Add all the currently known accounts.
48 for account_service in manager.get_enabled_account_services():47 for account_service in manager.get_enabled_account_services():
49 self._add_new_account(account_service)48 self._add_new_account(account_service)
@@ -63,25 +62,6 @@
63 account = self._add_new_account(account_service)62 account = self._add_new_account(account_service)
64 if account is not None:63 if account is not None:
65 account.protocol('receive')64 account.protocol('receive')
66 else:
67 # If an account has been disabled in UOA, we should remove
68 # it's messages from the SharedModel.
69 self._unpublish_entire_account(account_id)
70
71 def _on_account_deleted(self, manager, account_id):
72 account_service = self._get_service(manager, account_id)
73 if account_service is not None:
74 log.debug('Deleting account {}'.format(account_id))
75 self._unpublish_entire_account(account_id)
76 else:
77 log.error('Tried to delete invalid account: {}'.format(account_id))
78
79 def _unpublish_entire_account(self, account_id):
80 """Delete all the account's messages from the SharedModel."""
81 log.debug('Deleting all messages from {}.'.format(account_id))
82 account = self._accounts.pop(str(account_id), None)
83 if account is not None:
84 account.protocol._unpublish_all()
8565
86 def _add_new_account(self, account_service):66 def _add_new_account(self, account_service):
87 try:67 try:
@@ -89,14 +69,14 @@
89 except UnsupportedProtocolError as error:69 except UnsupportedProtocolError as error:
90 log.info(error)70 log.info(error)
91 else:71 else:
92 self._accounts[str(new_account.id)] = new_account72 self._accounts[new_account.id] = new_account
93 return new_account73 return new_account
9474
95 def get_all(self):75 def get_all(self):
96 return self._accounts.values()76 return self._accounts.values()
9777
98 def get(self, account_id, default=None):78 def get(self, account_id, default=None):
99 return self._accounts.get(str(account_id), default)79 return self._accounts.get(int(account_id), default)
10080
10181
102class AuthData:82class AuthData:
@@ -132,12 +112,12 @@
132 self.auth = AuthData(account_service.get_auth_data())112 self.auth = AuthData(account_service.get_auth_data())
133 # The provider in libaccounts should match the name of our protocol.113 # The provider in libaccounts should match the name of our protocol.
134 account = account_service.get_account()114 account = account_service.get_account()
115 self.id = account.id
135 self.protocol_name = account.get_provider_name()116 self.protocol_name = account.get_provider_name()
136 protocol_class = protocol_manager.protocols.get(self.protocol_name)117 protocol_class = protocol_manager.protocols.get(self.protocol_name)
137 if protocol_class is None:118 if protocol_class is None:
138 raise UnsupportedProtocolError(self.protocol_name)119 raise UnsupportedProtocolError(self.protocol_name)
139 self.protocol = protocol_class(self)120 self.protocol = protocol_class(self)
140 self.id = str(account.id)
141 # Connect responders to changes in the account information.121 # Connect responders to changes in the account information.
142 account_service.connect('changed', self._on_account_changed, account)122 account_service.connect('changed', self._on_account_changed, account)
143 self._on_account_changed(account_service, account)123 self._on_account_changed(account_service, account)
144124
=== modified file 'friends/utils/authentication.py'
--- friends/utils/authentication.py 2013-02-05 01:11:35 +0000
+++ friends/utils/authentication.py 2013-03-20 13:27:53 +0000
@@ -66,5 +66,10 @@
66 def _login_cb(self, session, reply, error, user_data):66 def _login_cb(self, session, reply, error, user_data):
67 self._reply = reply67 self._reply = reply
68 if error:68 if error:
69 raise AuthorizationError(self.account.id, error.message)69 exception = AuthorizationError(self.account.id, error.message)
70 # Mardy says this error can happen during normal operation.
71 if error.message.endswith('userActionFinished error: 10'):
72 log.error(str(exception))
73 else:
74 raise exception
70 log.debug('Login completed')75 log.debug('Login completed')
7176
=== modified file 'friends/utils/base.py'
--- friends/utils/base.py 2013-03-08 02:31:15 +0000
+++ friends/utils/base.py 2013-03-20 13:27:53 +0000
@@ -25,7 +25,6 @@
2525
26import re26import re
27import time27import time
28import string
29import logging28import logging
30import threading29import threading
3130
@@ -43,23 +42,49 @@
4342
4443
45STUB = lambda *ignore, **kwignore: None44STUB = lambda *ignore, **kwignore: None
46IGNORED = string.punctuation + string.whitespace
47SCHEME_RE = re.compile('http[s]?://|friends:/', re.IGNORECASE)
48EMPTY_STRING = ''
49COMMA_SPACE = ', '45COMMA_SPACE = ', '
50AVATAR_IDX = COLUMN_INDICES['icon_uri']46AVATAR_IDX = COLUMN_INDICES['icon_uri']
51FROM_ME_IDX = COLUMN_INDICES['from_me']47FROM_ME_IDX = COLUMN_INDICES['from_me']
52STREAM_IDX = COLUMN_INDICES['stream']48STREAM_IDX = COLUMN_INDICES['stream']
53SENDER_IDX = COLUMN_INDICES['sender']49SENDER_IDX = COLUMN_INDICES['sender']
54MESSAGE_IDX = COLUMN_INDICES['message']50MESSAGE_IDX = COLUMN_INDICES['message']
55IDS_IDX = COLUMN_INDICES['message_ids']51ID_IDX = COLUMN_INDICES['message_id']
52ACCT_IDX = COLUMN_INDICES['account_id']
56TIME_IDX = COLUMN_INDICES['timestamp']53TIME_IDX = COLUMN_INDICES['timestamp']
5754
5855# See friends/tests/test_protocols.py for further documentation
59# This is a mapping from Dee.SharedModel row keys to the DeeModelIters56LINKIFY_REGEX = re.compile(
60# representing the rows matching those keys. It is used for quickly finding57 r"""
61# duplicates when we want to insert new rows into the model.58 # Do not match if URL is preceded by '"' or '>'
62_seen_messages = {}59 # This is used to prevent duplication of linkification.
60 (?<![\"\>])
61 # Record everything that we're about to match.
62 (
63 # URLs can start with 'http://', 'https://', 'ftp://', or 'www.'
64 (?:(?:https?|ftp)://|www\.)
65 # Match many non-whitespace characters, but not greedily.
66 (?:\S+?)
67 # Stop recording the match.
68 )
69 # This section will peek ahead (without matching) in order to
70 # determine precisely where the URL actually *ends*.
71 (?=
72 # Do not include any trailing period, comma, exclamation mark,
73 # question mark, or closing parentheses, if any are present.
74 [.,!?\)]*
75 # With "trailing" defined as immediately preceding the first
76 # space, or end-of-string.
77 (?:\s|$)
78 # But abort the whole thing if the URL ends with '</a>',
79 # again to prevent duplication of linkification.
80 (?!</a>)
81 )""",
82 flags=re.VERBOSE).sub
83
84
85# This is a mapping from message_ids to DeeModel row index ints. It is
86# used for quickly and easily preventing the same message from being
87# published multiple times by mistake.
63_seen_ids = {}88_seen_ids = {}
6489
6590
@@ -67,9 +92,6 @@
67# publishing new data into the SharedModel.92# publishing new data into the SharedModel.
68_publish_lock = threading.Lock()93_publish_lock = threading.Lock()
6994
70# Avoid race condition during shut-down
71_exit_lock = threading.Lock()
72
7395
74log = logging.getLogger(__name__)96log = logging.getLogger(__name__)
7597
@@ -92,56 +114,27 @@
92 return method114 return method
93115
94116
95def _make_key(row):
96 """Return a unique key for a row in the model.
97
98 This is used for fuzzy comparisons with messages that are already in the
99 model. We don't want duplicate messages to show up in the stream of
100 messages that are visible to the user. But different social media sites
101 attach different semantic meanings to different punctuation marks, so we
102 want to ignore those for the sake of determining whether one message is
103 actually identical to another or not. Thus, we need to strip out this
104 punctuation for the sake of comparing the strings. For example:
105
106 Fred uses Friends to post identical messages on Twitter and Google+
107 (pretend that we support G+ for a moment). Fred writes 'Hey jimbob, been
108 to http://example.com lately?', and this message might show up on Twitter
109 like 'Hey @jimbob, been to example.com lately?', but it might show up on
110 G+ like 'Hey +jimbob, been to http://example.com lately?'. So we need to
111 strip out all the possibly different bits in order to identify that these
112 messages are the same for our purposes. In both of these cases, the
113 string is converted into 'Heyjimbobbeentoexamplecomlately' and then they
114 compare equally, so we've identified a duplicate message.
115 """
116 # Given a 'row' of data, the sender and message fields are concatenated
117 # together to form the raw key. Then we strip out details such as url
118 # schemes, punctuation, and whitespace, that allow for the fuzzy matching.
119 key = SCHEME_RE.sub('', row[SENDER_IDX] + row[MESSAGE_IDX])
120 # Now remove all punctuation and whitespace.
121 return EMPTY_STRING.join([char for char in key if char not in IGNORED])
122
123
124def initialize_caches():117def initialize_caches():
125 """Populate _seen_ids and _seen_messages with Model data.118 """Populate _seen_ids with Model data.
126119
127 Our Dee.SharedModel persists across instances, so we need to120 Our Dee.SharedModel persists across instances, so we need to
128 populate these caches at launch.121 populate this cache at launch.
129 """122 """
130 for i in range(Model.get_n_rows()):123 # Don't create a new dict; we need to keep the same dict object in
131 row_iter = Model.get_iter_at_row(i)124 # memory since it gets imported into a few different places that
132 row = Model.get_row(row_iter)125 # would not get the updated reference to the new dict.
133 _seen_messages[_make_key(row)] = i126 _seen_ids.clear()
134 for triple in row[IDS_IDX]:127 _seen_ids.update({row[ID_IDX]: i for i, row in enumerate(Model)})
135 _seen_ids[tuple(triple)] = i128 log.debug('_seen_ids: {}'.format(len(_seen_ids)))
136 log.debug(129
137 '_seen_ids: {}, _seen_messages: {}'.format(130
138 len(_seen_ids), len(_seen_messages)))131def linkify_string(string):
132 """Finds all URLs in a string and turns them into HTML links."""
133 return LINKIFY_REGEX(r'<a href="\1">\1</a>', string)
139134
140135
141class _OperationThread(threading.Thread):136class _OperationThread(threading.Thread):
142 """Manage async callbacks, and log subthread exceptions."""137 """Manage async callbacks, and log subthread exceptions."""
143 # main.py will replace this with a reference to the mainloop.quit method
144 shutdown = lambda: log.error('Failed to exit friends-dispatcher main loop')
145138
146 def __init__(self, *args, id=None, success=STUB, failure=STUB, **kws):139 def __init__(self, *args, id=None, success=STUB, failure=STUB, **kws):
147 self._id = id140 self._id = id
@@ -173,13 +166,6 @@
173 log.debug('{} has completed in {:.2f}s, thread exiting.'.format(166 log.debug('{} has completed in {:.2f}s, thread exiting.'.format(
174 self._id, elapsed))167 self._id, elapsed))
175168
176 # If this is the last thread to exit, then the refresh is
177 # completed and we should save the model, and then exit.
178 with _exit_lock:
179 if threading.activeCount() < 3:
180 persist_model()
181 GLib.idle_add(self.shutdown)
182
183169
184class Base:170class Base:
185 """Parent class for any protocol plugin such as Facebook or Twitter.171 """Parent class for any protocol plugin such as Facebook or Twitter.
@@ -213,6 +199,8 @@
213199
214 def __init__(self, account):200 def __init__(self, account):
215 self._account = account201 self._account = account
202 self._Name = self.__class__.__name__
203 self._name = self._Name.lower()
216204
217 def _whoami(self, result):205 def _whoami(self, result):
218 """Use OAuth login results to identify the authenticating user.206 """Use OAuth login results to identify the authenticating user.
@@ -240,7 +228,7 @@
240 """228 """
241 raise NotImplementedError(229 raise NotImplementedError(
242 '{} protocol has no _whoami() method.'.format(230 '{} protocol has no _whoami() method.'.format(
243 self.__class__.__name__))231 self._Name))
244232
245 def receive(self):233 def receive(self):
246 """Poll the social network for new messages.234 """Poll the social network for new messages.
@@ -279,7 +267,7 @@
279 """267 """
280 raise NotImplementedError(268 raise NotImplementedError(
281 '{} protocol has no receive() method.'.format(269 '{} protocol has no receive() method.'.format(
282 self.__class__.__name__))270 self._Name))
283271
284 def __call__(self, operation, *args, success=STUB, failure=STUB, **kwargs):272 def __call__(self, operation, *args, success=STUB, failure=STUB, **kwargs):
285 """Call an operation, i.e. a method, with arguments in a sub-thread.273 """Call an operation, i.e. a method, with arguments in a sub-thread.
@@ -311,7 +299,7 @@
311 raise NotImplementedError(operation)299 raise NotImplementedError(operation)
312 method = getattr(self, operation)300 method = getattr(self, operation)
313 _OperationThread(301 _OperationThread(
314 id='{}.{}'.format(self.__class__.__name__, operation),302 id='{}.{}'.format(self._Name, operation),
315 target=method,303 target=method,
316 success=success,304 success=success,
317 failure=failure,305 failure=failure,
@@ -323,7 +311,7 @@
323 """Return the number of rows in the Dee.SharedModel."""311 """Return the number of rows in the Dee.SharedModel."""
324 return len(Model)312 return len(Model)
325313
326 def _publish(self, message_id, **kwargs):314 def _publish(self, **kwargs):
327 """Publish fresh data into the model, ignoring duplicates.315 """Publish fresh data into the model, ignoring duplicates.
328316
329 This method inserts a new full row into the Dee.SharedModel317 This method inserts a new full row into the Dee.SharedModel
@@ -352,35 +340,31 @@
352 present. Otherwise, False is returned if the message could not be340 present. Otherwise, False is returned if the message could not be
353 appended.341 appended.
354 """342 """
355 # Initialize the row of arguments to contain the message_ids value.343 # These bits don't need to be set by the caller; we can infer them.
356 # The column value is a list of lists (see friends/utils/model.py for344 kwargs.update(
357 # details), and because the arguments are themselves a list, this gets345 dict(
358 # initialized as a triply-nested list.346 protocol=self._name,
359 triple = [self.__class__.__name__.lower(),347 account_id=self._account.id
360 self._account.id,348 )
361 message_id]349 )
362 args = [[triple]]350 # linkify the message
363 # Now iterate through all the column names listed in the SCHEMA,351 kwargs['message'] = linkify_string(kwargs.get('message', ''))
364 # except for the first, since we just composed its value in the352 args = []
365 # preceding line. Pop matching column values from the kwargs, in the353 # Now iterate through all the column names listed in the
366 # order which they appear in the SCHEMA. If any are left over at the354 # SCHEMA, and pop matching column values from the kwargs, in
367 # end of this, raise a TypeError indicating the unexpected column355 # the order which they appear in the SCHEMA. If any are left
368 # names.356 # over at the end of this, raise a TypeError indicating the
369 #357 # unexpected column names.
370 # Missing column values default to the empty string.358 for column_name, column_type in SCHEMA:
371 for column_name, column_type in SCHEMA[1:]:
372 args.append(kwargs.pop(column_name, DEFAULTS[column_type]))359 args.append(kwargs.pop(column_name, DEFAULTS[column_type]))
373 if len(kwargs) > 0:360 if len(kwargs) > 0:
374 raise TypeError('Unexpected keyword arguments: {}'.format(361 raise TypeError('Unexpected keyword arguments: {}'.format(
375 COMMA_SPACE.join(sorted(kwargs))))362 COMMA_SPACE.join(sorted(kwargs))))
376 with _publish_lock:363 with _publish_lock:
377 # Don't let duplicate messages into the model, but do record the364 message_id = args[ID_IDX]
378 # unique message ids of each duplicate message.365 # Don't let duplicate messages into the model
379 key = _make_key(args)366 if message_id not in _seen_ids:
380 row_idx = _seen_messages.get(key)367 _seen_ids[message_id] = Model.get_position(Model.append(*args))
381 if row_idx is None:
382 # We haven't seen this message before.
383 _seen_messages[key] = Model.get_position(Model.append(*args))
384 # I think it's safe not to notify the user about368 # I think it's safe not to notify the user about
385 # messages that they sent themselves...369 # messages that they sent themselves...
386 if not args[FROM_ME_IDX] and self._do_notify(args[STREAM_IDX]):370 if not args[FROM_ME_IDX] and self._do_notify(args[STREAM_IDX]):
@@ -389,25 +373,7 @@
389 args[MESSAGE_IDX],373 args[MESSAGE_IDX],
390 args[AVATAR_IDX],374 args[AVATAR_IDX],
391 )375 )
392 else:376 return message_id in _seen_ids
393 # We have seen this before, so append to the matching column's
394 # message_ids list, this message's id.
395 row = Model.get_row(Model.get_iter_at_row(row_idx))
396 # Remember that row[IDS] is the nested list-of-lists of
397 # message_ids. args[IDS] is the nested list-of-lists for the
398 # message that we're publishing. The outer list of the latter
399 # will always be of size 1. We want to take the inner list
400 # from args and append it to the list-of-lists (i.e.
401 # message_ids) of the row already in the model. To make sure
402 # the model gets updated, we need to insert into the row, thus
403 # it's best to concatenate the two lists together and store it
404 # back into the column.
405 if triple not in row[IDS_IDX]:
406 row[IDS_IDX] = row[IDS_IDX] + args[IDS_IDX]
407 # Tuple-ize triple because lists, being mutable, cannot be used as
408 # dictionary keys.
409 _seen_ids[tuple(triple)] = _seen_messages.get(key)
410 return key in _seen_messages
411377
412 def _unpublish(self, message_id):378 def _unpublish(self, message_id):
413 """Remove message_id from the Dee.SharedModel.379 """Remove message_id from the Dee.SharedModel.
@@ -416,45 +382,24 @@
416 published.382 published.
417 :type message_id: string383 :type message_id: string
418 """384 """
419 triple = (self.__class__.__name__.lower(),385 log.debug('Unpublishing {}!'.format(message_id))
420 self._account.id,
421 message_id)
422 log.debug('Unpublishing {}!'.format(triple))
423386
424 row_idx = _seen_ids.pop(triple, None)387 row_idx = _seen_ids.pop(message_id, None)
425 if row_idx is None:388 if row_idx is None:
426 raise FriendsError('Tried to delete an invalid message id.')389 raise FriendsError('Tried to delete an invalid message id.')
427390
428 row_iter = Model.get_iter_at_row(row_idx)391 Model.remove(Model.get_iter_at_row(row_idx))
429 row = Model.get_row(row_iter)392
430393 # Shift our cached indexes up one, when one gets deleted.
431 if len(row[IDS_IDX]) == 1:394 for key, value in _seen_ids.items():
432 # Message only exists on one protocol, delete it395 if value > row_idx:
433 del _seen_messages[_make_key(row)]396 _seen_ids[key] = value - 1
434 Model.remove(row_iter)
435 # Shift our cached indexes up one, when one gets deleted.
436 for key, value in _seen_ids.items():
437 if value > row_idx:
438 _seen_ids[key] = value - 1
439 else:
440 # Message exists on other protocols too, only drop id
441 row[IDS_IDX] = [ids for ids
442 in row[IDS_IDX]
443 if ids[-1] != message_id]
444
445 def _unpublish_all(self):
446 """Remove all of this account's messages from the Model.
447
448 Saves the Model to disk after it is done purging rows."""
449 for triple in _seen_ids.copy():
450 if self._account.id in triple:
451 self._unpublish(triple[-1])
452 persist_model()
453397
454 def _get_access_token(self):398 def _get_access_token(self):
455 """Return an access token, logging in if necessary.399 """Return an access token, logging in if necessary.
456400
457 :return: The access_token, if we are successfully logged in."""401 :return: The access_token, if we are successfully logged in.
402 """
458 if self._account.access_token is None:403 if self._account.access_token is None:
459 self._login()404 self._login()
460405
@@ -512,15 +457,14 @@
512 subthread needs to log in. You do not have to worry about457 subthread needs to log in. You do not have to worry about
513 subthread race conditions inside this method.458 subthread race conditions inside this method.
514 """459 """
515 protocol = self.__class__.__name__
516 log.debug('{} to {}'.format(460 log.debug('{} to {}'.format(
517 'Re-authenticating' if old_token else 'Logging in', protocol))461 'Re-authenticating' if old_token else 'Logging in', self._Name))
518462
519 result = Authentication(self._account).login()463 result = Authentication(self._account).login()
520464
521 self._account.access_token = result.get('AccessToken')465 self._account.access_token = result.get('AccessToken')
522 self._whoami(result)466 self._whoami(result)
523 log.debug('{} UID: {}'.format(protocol, self._account.user_id))467 log.debug('{} UID: {}'.format(self._Name, self._account.user_id))
524468
525 def _get_oauth_headers(self, method, url, data=None, headers=None):469 def _get_oauth_headers(self, method, url, data=None, headers=None):
526 """Basic wrapper around oauthlib that we use for Twitter and Flickr."""470 """Basic wrapper around oauthlib that we use for Twitter and Flickr."""
@@ -559,6 +503,16 @@
559 message = None503 message = None
560 raise FriendsError(message or str(error))504 raise FriendsError(message or str(error))
561505
506 def _fetch_cell(self, message_id, column_name):
507 """Find a column value associated with a specific message_id."""
508 row_id = _seen_ids.get(message_id)
509 col_idx = COLUMN_INDICES.get(column_name)
510 if None not in (row_id, col_idx):
511 row = Model.get_row(row_id)
512 return row[col_idx]
513 else:
514 raise FriendsError('Value could not be found.')
515
562 def _new_book_client(self, source):516 def _new_book_client(self, source):
563 client = EBook.BookClient.new(source)517 client = EBook.BookClient.new(source)
564 client.open_sync(False, None)518 client.open_sync(False, None)
565519
=== modified file 'friends/utils/model.py'
--- friends/utils/model.py 2013-02-05 01:11:35 +0000
+++ friends/utils/model.py 2013-03-20 13:27:53 +0000
@@ -41,26 +41,11 @@
41log = logging.getLogger(__name__)41log = logging.getLogger(__name__)
4242
4343
44# Most of this schema is very straightforward, but the 'message_ids' column44# DO NOT EDIT THIS WITHOUT ADJUSTING service.vala IN LOCKSTEP
45# needs a bit of explanation:
46#
47# It is a two-dimensional array (ie, an array of arrays). Each inner
48# array contains three elements: the name of the protocol
49# (introspected from the name of the class that implements the
50# protocol), the account_id as a string (like '6' or '3'), followed by
51# the message_id for that particular service.
52#
53# Then, there will be one of these triples present for every service on which
54# the message exists. So for example, if the user posts the same message to
55# both facebook and twitter, that message will appear as a single row in this
56# schema, and the 'message_ids' column will look something like this:
57#
58# [
59# ['facebook', '2', '12345'],
60# ['twitter', '3', '987654'],
61# ]
62SCHEMA = (45SCHEMA = (
63 ('message_ids', 'aas'),46 ('protocol', 's'), # Same as UOA 'provider_name'
47 ('account_id', 't'), # Same as UOA account id
48 ('message_id', 's'),
64 ('stream', 's'),49 ('stream', 's'),
65 ('sender', 's'),50 ('sender', 's'),
66 ('sender_id', 's'),51 ('sender_id', 's'),
@@ -70,7 +55,7 @@
70 ('message', 's'),55 ('message', 's'),
71 ('icon_uri', 's'),56 ('icon_uri', 's'),
72 ('url', 's'),57 ('url', 's'),
73 ('likes', 'd'),58 ('likes', 't'),
74 ('liked', 'b'),59 ('liked', 'b'),
75 ('link_picture', 's'),60 ('link_picture', 's'),
76 ('link_name', 's'),61 ('link_name', 's'),
@@ -78,6 +63,9 @@
78 ('link_desc', 's'),63 ('link_desc', 's'),
79 ('link_caption', 's'),64 ('link_caption', 's'),
80 ('link_icon', 's'),65 ('link_icon', 's'),
66 ('location', 's'),
67 ('latitude', 'd'),
68 ('longitude', 'd'),
81 )69 )
8270
8371
@@ -91,6 +79,7 @@
91 'b': False,79 'b': False,
92 's': '',80 's': '',
93 'd': 0,81 'd': 0,
82 't': 0,
94 }83 }
9584
9685
9786
=== modified file 'service/configure.ac'
--- service/configure.ac 2013-02-07 23:36:14 +0000
+++ service/configure.ac 2013-03-20 13:27:53 +0000
@@ -18,6 +18,7 @@
1818
19DEE_REQUIRED=1.0.019DEE_REQUIRED=1.0.0
20PKG_CHECK_MODULES(BASE,20PKG_CHECK_MODULES(BASE,
21 libaccounts-glib
21 gio-2.022 gio-2.0
22 dee-1.0 >= $DEE_REQUIRED)23 dee-1.0 >= $DEE_REQUIRED)
2324
2425
=== modified file 'service/src/Makefile.am'
--- service/src/Makefile.am 2013-02-04 19:42:37 +0000
+++ service/src/Makefile.am 2013-03-20 13:27:53 +0000
@@ -2,14 +2,15 @@
2 friends-service2 friends-service
33
4INCLUDES = \4INCLUDES = \
5 $(BASE_CFLAGS) 5 $(BASE_CFLAGS)
66
7VALAFLAGS = \7VALAFLAGS = \
8 --pkg accounts \
8 --pkg dee-1.0 \9 --pkg dee-1.0 \
9 --pkg gio-2.010 --pkg gio-2.0
1011
11friends_service_LDADD = \12friends_service_LDADD = \
12 $(BASE_LIBS) 13 $(BASE_LIBS)
1314
14friends_service_SOURCES = \15friends_service_SOURCES = \
15 service.vala16 service.vala
1617
=== modified file 'service/src/service.vala'
--- service/src/service.vala 2013-02-20 13:24:44 +0000
+++ service/src/service.vala 2013-03-20 13:27:53 +0000
@@ -16,6 +16,7 @@
16 * Authored by Ken VanDine <ken.vandine@canonical.com>16 * Authored by Ken VanDine <ken.vandine@canonical.com>
17 */17 */
1818
19using Ag;
1920
20[DBus (name = "com.canonical.Friends.Dispatcher")]21[DBus (name = "com.canonical.Friends.Dispatcher")]
21private interface Dispatcher : GLib.Object {22private interface Dispatcher : GLib.Object {
@@ -35,11 +36,34 @@
35 private Dee.Model model;36 private Dee.Model model;
36 private Dee.SharedModel shared_model;37 private Dee.SharedModel shared_model;
37 private unowned Dee.ResourceManager resources;38 private unowned Dee.ResourceManager resources;
39 private Ag.Manager acct_manager;
38 private Dispatcher dispatcher;40 private Dispatcher dispatcher;
39 public int interval { get; set; }41 public int interval { get; set; }
4042
41 public Master ()43 public Master ()
42 {44 {
45 acct_manager = new Ag.Manager.for_service_type ("microblogging");
46 acct_manager.account_deleted.connect ((manager, account_id) => {
47 debug ("Account %u deleted from UOA, purging...", account_id);
48 uint purged = 0;
49 uint rows = model.get_n_rows ();
50 // Destructively iterate over the Model from back to
51 // front; I know "i < rows" looks kinda goofy here,
52 // but what's happening is that i is unsigned, so once
53 // it hits 0, i-- will overflow to a very large
54 // number, and then "i < rows" will fail, stopping the
55 // iteration at index 0.
56 for (uint i = rows - 1; i < rows; i--) {
57 var itr = model.get_iter_at_row (i);
58 if (model.get_uint64 (itr, 1) == account_id) {
59 model.remove (itr);
60 purged++;
61 }
62 }
63 debug ("Purged %u rows.", purged);
64 }
65 );
66
43 resources = Dee.ResourceManager.get_default ();67 resources = Dee.ResourceManager.get_default ();
44 model = new Dee.SequenceModel ();68 model = new Dee.SequenceModel ();
45 Dee.SequenceModel? _m = null;69 Dee.SequenceModel? _m = null;
@@ -50,24 +74,29 @@
50 debug ("Failed to load model from resource manager: %s", e.message);74 debug ("Failed to load model from resource manager: %s", e.message);
51 }75 }
5276
53 string[] SCHEMA = {"aas",77 string[] SCHEMA = {"s",
54 "s",78 "t",
55 "s",79 "s",
56 "s",80 "s",
57 "s",81 "s",
58 "b",82 "s",
83 "s",
84 "b",
85 "s",
86 "s",
87 "s",
88 "s",
89 "t",
90 "b",
91 "s",
92 "s",
93 "s",
59 "s",94 "s",
60 "s",95 "s",
61 "s",96 "s",
62 "s",97 "s",
63 "d",98 "d",
64 "b",99 "d"};
65 "s",
66 "s",
67 "s",
68 "s",
69 "s",
70 "s"};
71100
72 bool schemaReset = false;101 bool schemaReset = false;
73102
@@ -77,7 +106,7 @@
77 // Compare columns from cached model's schema106 // Compare columns from cached model's schema
78 string[] _SCHEMA = _m.get_schema ();107 string[] _SCHEMA = _m.get_schema ();
79 if (_SCHEMA.length != SCHEMA.length)108 if (_SCHEMA.length != SCHEMA.length)
80 schemaReset = true; 109 schemaReset = true;
81 else110 else
82 {111 {
83 for (int i=0; i < _SCHEMA.length;i++ )112 for (int i=0; i < _SCHEMA.length;i++ )
@@ -87,7 +116,6 @@
87 debug ("SCHEMA MISMATCH");116 debug ("SCHEMA MISMATCH");
88 schemaReset = true;117 schemaReset = true;
89 }118 }
90
91 }119 }
92 }120 }
93 if (!schemaReset)121 if (!schemaReset)
94122
=== modified file 'setup.py'
--- setup.py 2013-02-05 01:11:35 +0000
+++ setup.py 2013-03-20 13:27:53 +0000
@@ -29,6 +29,7 @@
29 include_package_data=True,29 include_package_data=True,
30 package_data = {30 package_data = {
31 'friends.service.templates': ['*.service.in'],31 'friends.service.templates': ['*.service.in'],
32 'friends.tests.data': ['*.dat'],
32 },33 },
33 data_files = [34 data_files = [
34 ('/usr/share/glib-2.0/schemas',35 ('/usr/share/glib-2.0/schemas',
3536
=== modified file 'tools/debug_live.py'
--- tools/debug_live.py 2013-02-19 09:27:53 +0000
+++ tools/debug_live.py 2013-03-20 13:27:53 +0000
@@ -59,7 +59,7 @@
59 Model.connect('row-added', row_added)59 Model.connect('row-added', row_added)
6060
61 for account in a._accounts.values():61 for account in a._accounts.values():
62 if account.protocol.__class__.__name__.lower() == protocol.lower():62 if account.protocol._name == protocol.lower():
63 found = True63 found = True
64 account.protocol(*args)64 account.protocol(*args)
6565

Subscribers

People subscribed via source and target branches

to all changes: