Merge lp:~andrewsomething/friends/instagram into lp:friends

Proposed by Andrew Starr-Bochicchio on 2013-04-12
Status: Superseded
Proposed branch: lp:~andrewsomething/friends/instagram
Merge into: lp:friends
Diff against target: 3409 lines (+1444/-824) (has conflicts)
42 files modified
Makefile (+1/-1)
debian/changelog (+13/-0)
debian/control (+6/-0)
debian/friends-dispatcher.install (+0/-1)
debian/friends-instagram.install (+1/-0)
friends/errors.py (+21/-0)
friends/main.py (+7/-16)
friends/protocols/flickr.py (+3/-3)
friends/protocols/instagram.py (+200/-0)
friends/protocols/twitter.py (+2/-4)
friends/service/dispatcher.py (+27/-34)
friends/shorteners/base.py (+0/-35)
friends/shorteners/isgd.py (+0/-32)
friends/shorteners/linkeecom.py (+0/-29)
friends/shorteners/ougd.py (+0/-29)
friends/shorteners/tinyurlcom.py (+0/-32)
friends/tests/data/durlme.dat (+6/-0)
friends/tests/data/instagram-full.dat (+473/-0)
friends/tests/data/instagram-login.dat (+18/-0)
friends/tests/mocks.py (+10/-29)
friends/tests/test_account.py (+41/-120)
friends/tests/test_authentication.py (+36/-39)
friends/tests/test_dispatcher.py (+38/-44)
friends/tests/test_facebook.py (+11/-9)
friends/tests/test_flickr.py (+8/-2)
friends/tests/test_foursquare.py (+6/-0)
friends/tests/test_identica.py (+6/-0)
friends/tests/test_instagram.py (+215/-0)
friends/tests/test_logging.py (+39/-88)
friends/tests/test_shortener.py (+76/-44)
friends/tests/test_twitter.py (+13/-21)
friends/utils/account.py (+36/-104)
friends/utils/authentication.py (+28/-10)
friends/utils/avatar.py (+11/-20)
friends/utils/base.py (+3/-3)
friends/utils/cache.py (+3/-4)
friends/utils/logging.py (+8/-10)
friends/utils/menus.py (+3/-3)
friends/utils/notify.py (+5/-7)
friends/utils/shorteners.py (+58/-40)
friends/utils/time.py (+3/-4)
tools/debug_live.py (+9/-7)
Text conflict in debian/changelog
To merge this branch: bzr merge lp:~andrewsomething/friends/instagram
Reviewer Review Type Date Requested Status
Robert Bruce Park 2013-04-12 Resubmit on 2013-04-16
Review via email: mp+158694@code.launchpad.net

This proposal has been superseded by a proposal from 2013-04-16.

Description of the change

This isn't entirely ready to land yet, but I'd like to get some review.
There are two issues:

1) Likes get successfully sent but the heart in friends-app is never
colored in and the progress spinner never stops. I think this is actually
a bug somewhere else in friends though as the same thing happens with
Facebook. Here's the terminal output:

** (process:5907): CRITICAL **: file /build/buildd/libfriends-0.1.2daily13.03.26/src/dispatcher.vala: line 146: uncaught error: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message did not receive a reply (timeout by message bus) (g-dbus-error-quark, 4)

2) Comment posting isn't completely tested yet. Instagram returns:

{'meta': {'error_message': 'Please visit http://bit.ly/instacomments for commenting access', 'error_type': 'APIError', 'code': 400}}

I've visited the link and asked for access, but I haven't gotten a response
yet. Will have to wait on that...

I also have one question. Is there annyway to make pictures a first class
citizen? Instagram doesn't have messages perse. Currently I just use
'foo shared a picture on Instagram.' as the message and then you need to
click to see the picture. It would be nice if it we possible to use
the picture as the message.

To post a comment you must log in.
Robert Bruce Park (robru) wrote :

Wow, quite speedy ;-)

I don't have time to review this right now (and it's too late to make it into raring anyway), but I promise I'll look at it soon and we'll land it early in the S cycle.

Ya, I wanted to get it done while my motivation was at its height even though its too late for raring.

I filed a bug about the issue liking things (LP: #1168536).

Robert Bruce Park (robru) wrote :

Oh, andrew, can you rebase this on lp:~super-friends/friends/trunk-next? That is where we're landing new features before S opens for development. Shouldn't be any merge conflicts as far as I know.

review: Resubmit
198. By Andrew Starr-Bochicchio on 2013-04-16

Merge on lp:~super-friends/friends/trunk-next

199. By Andrew Starr-Bochicchio on 2013-04-16

Add test_error_response

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2013-02-05 01:11:35 +0000
3+++ Makefile 2013-04-16 17:53:25 +0000
4@@ -14,7 +14,7 @@
5 # along with this program. If not, see <http://www.gnu.org/licenses/>.
6
7 check:
8- python3 -m unittest discover -vv
9+ python3 -m unittest discover
10
11 install:
12 python3 setup.py install
13
14=== modified file 'debian/changelog'
15--- debian/changelog 2013-04-16 15:51:44 +0000
16+++ debian/changelog 2013-04-16 17:53:25 +0000
17@@ -1,3 +1,4 @@
18+<<<<<<< TREE
19 friends (0.1.3daily13.04.16.1~13.04-0ubuntu1) raring; urgency=low
20
21 [ Ken VanDine ]
22@@ -12,6 +13,18 @@
23
24 -- Ubuntu daily release <ps-jenkins@lists.canonical.com> Tue, 16 Apr 2013 15:51:43 +0000
25
26+=======
27+friends (0.2.0-0ubuntu1) UNRELEASED; urgency=low
28+
29+ [ Robert Bruce Park ]
30+ * Version bump for the next development series.
31+
32+ [Andrew Starr-Bochicchio]
33+ * Add support for Instagram (LP: #1167449).
34+
35+ -- Robert Bruce Park <robert.park@canonical.com> Tue, 19 Mar 2013 15:03:58 -0700
36+
37+>>>>>>> MERGE-SOURCE
38 friends (0.1.3daily13.04.10.1-0ubuntu1) raring; urgency=low
39
40 [ Robert Bruce Park ]
41
42=== modified file 'debian/control'
43--- debian/control 2013-04-16 15:40:42 +0000
44+++ debian/control 2013-04-16 17:53:25 +0000
45@@ -118,3 +118,9 @@
46 account-plugin-flickr,
47 Description: Social integration with the desktop - Flickr
48 Provides social networking integration with the desktop
49+
50+Package: friends-instagram
51+Architecture: all
52+Depends: friends, ${misc:Depends}, ${python3:Depends}
53+Description: Social integration with the desktop - Instagram
54+ Provides social networking integration with the desktop
55
56=== modified file 'debian/friends-dispatcher.install'
57--- debian/friends-dispatcher.install 2013-03-14 20:25:42 +0000
58+++ debian/friends-dispatcher.install 2013-04-16 17:53:25 +0000
59@@ -3,7 +3,6 @@
60 usr/lib/python3/dist-packages/friends/*.py
61 usr/lib/python3/dist-packages/friends/protocols/__init__.py
62 usr/lib/python3/dist-packages/friends/service/*
63-usr/lib/python3/dist-packages/friends/shorteners/*
64 usr/lib/python3/dist-packages/friends/utils/*
65 usr/lib/python3/dist-packages/friends/tests/mocks.py
66 usr/lib/python3/dist-packages/friends/tests/__init__.py
67
68=== added file 'debian/friends-instagram.install'
69--- debian/friends-instagram.install 1970-01-01 00:00:00 +0000
70+++ debian/friends-instagram.install 2013-04-16 17:53:25 +0000
71@@ -0,0 +1,1 @@
72+usr/lib/python3/dist-packages/friends/protocols/instagram*
73
74=== modified file 'friends/errors.py'
75--- friends/errors.py 2013-02-05 01:11:35 +0000
76+++ friends/errors.py 2013-04-16 17:53:25 +0000
77@@ -23,6 +23,27 @@
78 ]
79
80
81+try:
82+ from contextlib import ignored
83+except ImportError:
84+ from contextlib import contextmanager
85+
86+ # This is a fun toy from Python 3.4, but I want it NOW!!
87+ @contextmanager
88+ def ignored(*exceptions):
89+ """Context manager to ignore specifed exceptions.
90+
91+ with ignored(OSError):
92+ os.remove(somefile)
93+
94+ Thanks to Raymond Hettinger for this.
95+ """
96+ try:
97+ yield
98+ except exceptions:
99+ pass
100+
101+
102 class FriendsError(Exception):
103 """Base class for all internal Friends exceptions."""
104
105
106=== modified file 'friends/main.py'
107--- friends/main.py 2013-03-20 23:20:09 +0000
108+++ friends/main.py 2013-04-16 17:53:25 +0000
109@@ -36,6 +36,7 @@
110 DBusGMainLoop(set_as_default=True)
111 loop = GLib.MainLoop()
112
113+from friends.errors import ignored
114
115 # Short-circuit everything else if we are going to enter test-mode.
116 from friends.utils.options import Options
117@@ -48,10 +49,8 @@
118 populate_fake_data()
119 Dispatcher()
120
121- try:
122+ with ignored(KeyboardInterrupt):
123 loop.run()
124- except KeyboardInterrupt:
125- pass
126
127 sys.exit(0)
128
129@@ -102,11 +101,8 @@
130 sys.exit('friends-dispatcher is already running! Abort!')
131
132 if args.performance:
133- try:
134+ with ignored(ImportError):
135 import yappi
136- except ImportError:
137- pass
138- else:
139 yappi.start()
140
141 # Initialize the logging subsystem.
142@@ -138,11 +134,9 @@
143 # Don't initialize caches until the model is synchronized
144 Model.connect('notify::synchronized', setup)
145
146- try:
147+ with ignored(KeyboardInterrupt):
148 log.info('Starting friends-dispatcher main loop')
149 loop.run()
150- except KeyboardInterrupt:
151- pass
152
153 log.info('Stopped friends-dispatcher main loop')
154
155@@ -167,13 +161,10 @@
156 # data for the purposes of faster duplicate checks.
157 initialize_caches()
158
159- # Allow publishing.
160- try:
161+ # Exception indicates that lock was already released, which is harmless.
162+ with ignored(RuntimeError):
163+ # Allow publishing.
164 _publish_lock.release()
165- except RuntimeError:
166- # Happens if the lock was already released previously, which
167- # is safe to ignore. Dispatcher goes on to publish normally.
168- pass
169
170
171 if __name__ == '__main__':
172
173=== modified file 'friends/protocols/flickr.py'
174--- friends/protocols/flickr.py 2013-03-25 23:31:22 +0000
175+++ friends/protocols/flickr.py 2013-04-16 17:53:25 +0000
176@@ -82,7 +82,7 @@
177 # http://www.flickr.com/services/api/flickr.people.getInfo.html
178 def _get_avatar(self, nsid):
179 args = dict(
180- api_key=self._account.auth.parameters.get('ConsumerKey'),
181+ api_key=self._account.consumer_key,
182 method='flickr.people.getInfo',
183 format='json',
184 nojsoncallback='1',
185@@ -109,7 +109,7 @@
186 self._get_access_token()
187
188 args = dict(
189- api_key=self._account.auth.parameters.get('ConsumerKey'),
190+ api_key=self._account.consumer_key,
191 method='flickr.photos.getContactsPhotos',
192 format='json',
193 nojsoncallback='1',
194@@ -186,7 +186,7 @@
195 self._get_access_token()
196
197 args = dict(
198- api_key=self._account.auth.parameters.get('ConsumerKey'),
199+ api_key=self._account.consumer_key,
200 title=title,
201 )
202
203
204=== added file 'friends/protocols/instagram.py'
205--- friends/protocols/instagram.py 1970-01-01 00:00:00 +0000
206+++ friends/protocols/instagram.py 2013-04-16 17:53:25 +0000
207@@ -0,0 +1,200 @@
208+# friends-dispatcher -- send & receive messages from any social network
209+# Copyright (C) 2013 Canonical Ltd
210+#
211+# This program is free software: you can redistribute it and/or modify
212+# it under the terms of the GNU General Public License as published by
213+# the Free Software Foundation, version 3 of the License.
214+#
215+# This program is distributed in the hope that it will be useful,
216+# but WITHOUT ANY WARRANTY; without even the implied warranty of
217+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
218+# GNU General Public License for more details.
219+#
220+# You should have received a copy of the GNU General Public License
221+# along with this program. If not, see <http://www.gnu.org/licenses/>.
222+
223+"""The Instagram protocol plugin."""
224+
225+
226+__all__ = [
227+ 'Instagram',
228+ ]
229+
230+import time
231+import logging
232+
233+from friends.utils.avatar import Avatar
234+from friends.utils.base import Base, feature
235+from friends.utils.cache import JsonCache
236+from friends.utils.http import Downloader, Uploader
237+from friends.utils.time import parsetime, iso8601utc
238+from friends.errors import FriendsError
239+
240+log = logging.getLogger(__name__)
241+
242+class Instagram(Base):
243+ _api_base = 'https://api.instagram.com/v1/{endpoint}?access_token={token}'
244+ def _whoami(self, authdata):
245+ """Identify the authenticating user."""
246+ url = self._api_base.format(
247+ endpoint='users/self',
248+ token=self._get_access_token())
249+ result = Downloader(url).get_json()
250+ self._account.user_id = result.get('data').get('id')
251+ self._account.user_name = result.get('data').get('username')
252+
253+ def _publish_entry(self, entry, stream='messages'):
254+ """Publish a single update into the Dee.SharedModel."""
255+ message_id = entry.get('id')
256+
257+ if message_id is None:
258+ # We can't do much with this entry.
259+ return
260+
261+ person = entry.get('user')
262+ nick = person.get('username')
263+ name = person.get('full_name')
264+ person_id = person.get('id')
265+ message= '%s shared a picture on Instagram.' % nick
266+ person_icon = Avatar.get_image(person.get('profile_picture'))
267+ person_url = 'http://instagram.com/' + nick
268+ picture = entry.get('images').get('thumbnail').get('url')
269+ if entry.get('caption'):
270+ desc = entry.get('caption').get('text', '')
271+ else:
272+ desc = ''
273+ url = entry.get('link')
274+ timestamp = entry.get('created_time')
275+ if timestamp is not None:
276+ timestamp = iso8601utc(parsetime(timestamp))
277+ likes = entry.get('likes').get('count')
278+ liked = entry.get('user_has_liked')
279+ location = entry.get('location', {})
280+ if location:
281+ latitude = location.get('latitude', '')
282+ longitude = location.get('longitude', '')
283+ else:
284+ latitude = 0
285+ longitude = 0
286+
287+ args = dict(
288+ message_id=message_id,
289+ message=message,
290+ stream=stream,
291+ likes=likes,
292+ sender_id=person_id,
293+ sender=name,
294+ sender_nick=nick,
295+ url=person_url,
296+ icon_uri=person_icon,
297+ link_url=url,
298+ link_picture=picture,
299+ link_desc=desc,
300+ timestamp=timestamp,
301+ liked=liked,
302+ latitude=latitude,
303+ longitude=longitude
304+ )
305+
306+ self._publish(**args)
307+
308+ # If there are any replies, publish them as well.
309+ parent_id = message_id
310+ for comment in entry.get('comments', {}).get('data', []):
311+ if comment:
312+ self._publish_comment(
313+ comment, stream='reply_to/{}'.format(parent_id))
314+ return args['url']
315+
316+ def _publish_comment(self, comment, stream):
317+ message_id = comment.get('id')
318+ if message_id is None:
319+ return
320+ message = comment.get('text', '')
321+ person = comment.get('from', {})
322+ sender_nick = person.get('username')
323+ timestamp = comment.get('created_time')
324+ if timestamp is not None:
325+ timestamp = iso8601utc(parsetime(timestamp))
326+ icon_uri = Avatar.get_image(person.get('profile_picture'))
327+ sender_id = person.get('id')
328+ sender = person.get('full_name')
329+
330+ args = dict(
331+ stream=stream,
332+ message_id=message_id,
333+ message=message,
334+ timestamp=timestamp,
335+ sender_nick=sender_nick,
336+ icon_uri=icon_uri,
337+ sender_id=sender_id,
338+ sender=sender,
339+ )
340+# print(args)
341+ self._publish(**args)
342+
343+ @feature
344+ def home(self):
345+ """Gather and publish public timeline messages."""
346+ url = self._api_base.format(
347+ endpoint='users/self/feed',
348+ token=self._get_access_token())
349+ result = Downloader(url).get_json()
350+ values = result.get('data', {})
351+ for update in values:
352+ self._publish_entry(update)
353+
354+ @feature
355+ def receive(self):
356+ """Gather and publish all incoming messages."""
357+ self.home()
358+ return self._get_n_rows()
359+
360+ def _send(self, obj_id, message, endpoint, stream='messages'):
361+ token = self._get_access_token()
362+
363+ url = self._api_base.format(endpoint=endpoint, token=token)
364+
365+ result = Downloader(
366+ url,
367+ method='POST',
368+ params=dict(access_token=token, text=message)).get_json()
369+ new_id = result.get('id')
370+ if new_id is None:
371+ raise FriendsError('Failed sending to Instagram: {!r}'.format(result))
372+ enpoint = 'media/{}/comments'.format(new_id)
373+ url = self._api_base.format(endpoint=endpoint, token=token)
374+ comment = Downloader(url, params=dict(access_token=token)).get_json()
375+ return self._publish_entry(entry=comment, stream=stream)
376+
377+ @feature
378+ def send_thread(self, obj_id, message):
379+ """Write a comment on some existing picture.
380+ """
381+ return self._send(obj_id, message, 'media/{}/comments'.format(obj_id),
382+ stream='reply_to/{}'.format(obj_id))
383+
384+ def _like(self, obj_id, endpoint, method):
385+ token = self._get_access_token()
386+ url = self._api_base.format(endpoint=endpoint, token=token)
387+
388+ if not Downloader(url, method=method,
389+ params=dict(access_token=token)).get_json():
390+ raise FriendsError('Failed to {} like {} on Instagram'.format(
391+ method, obj_id))
392+
393+ @feature
394+ def like(self, obj_id):
395+ endpoint = 'media/{}/likes'.format(obj_id)
396+ self._like(obj_id, endpoint, 'POST')
397+ self._inc_cell(obj_id, 'likes')
398+ self._set_cell(obj_id, 'liked', True)
399+ return obj_id
400+
401+ @feature
402+ def unlike(self, obj_id):
403+ endpoint = 'media/{}/likes'.format(obj_id)
404+ self._like(obj_id, endpoint, 'DELETE')
405+ self._dec_cell(obj_id, 'likes')
406+ self._set_cell(obj_id, 'liked', False)
407+ return obj_id
408
409=== modified file 'friends/protocols/twitter.py'
410--- friends/protocols/twitter.py 2013-04-12 22:06:57 +0000
411+++ friends/protocols/twitter.py 2013-04-16 17:53:25 +0000
412@@ -32,7 +32,7 @@
413 from friends.utils.cache import JsonCache
414 from friends.utils.http import BaseRateLimiter, Downloader
415 from friends.utils.time import parsetime, iso8601utc
416-from friends.errors import FriendsError
417+from friends.errors import FriendsError, ignored
418
419
420 TWITTER_ADDRESS_BOOK = 'friends-twitter-contacts'
421@@ -261,12 +261,10 @@
422 consider it a regular message, and it won't be part of any
423 conversation.
424 """
425- try:
426+ with ignored(FriendsError):
427 sender = '@{}'.format(self._fetch_cell(message_id, 'sender_nick'))
428 if message.find(sender) < 0:
429 message = sender + ' ' + message
430- except FriendsError:
431- pass
432 url = self._api_base.format(endpoint='statuses/update')
433 tweet = self._get_url(url, dict(in_reply_to_status_id=message_id,
434 status=message))
435
436=== modified file 'friends/service/dispatcher.py'
437--- friends/service/dispatcher.py 2013-03-27 03:41:27 +0000
438+++ friends/service/dispatcher.py 2013-04-16 17:53:25 +0000
439@@ -31,11 +31,12 @@
440 from contextlib import ContextDecorator
441
442 from friends.utils.avatar import Avatar
443-from friends.utils.account import AccountManager
444+from friends.utils.account import find_accounts
445 from friends.utils.manager import protocol_manager
446 from friends.utils.menus import MenuManager
447 from friends.utils.model import Model, persist_model
448-from friends.shorteners import lookup
449+from friends.utils.shorteners import Short
450+from friends.errors import ignored
451
452
453 log = logging.getLogger(__name__)
454@@ -52,6 +53,7 @@
455 """Exit the dispatcher 30s after the most recent method call returns."""
456 timers = set()
457 callback = STUB
458+ timeout = 30
459
460 def __enter__(self):
461 self.clear_all_timers()
462@@ -60,16 +62,17 @@
463 self.set_new_timer()
464
465 def clear_all_timers(self):
466- log.debug('Clearing {} shutdown timer(s)...'.format(len(self.timers)))
467 while self.timers:
468- GLib.source_remove(self.timers.pop())
469+ timer_id = self.timers.pop()
470+ log.debug('Clearing timer id: {}'.format(timer_id))
471+ GLib.source_remove(timer_id)
472
473 def set_new_timer(self):
474 # Concurrency will cause two methods to exit near each other,
475 # causing two timers to be set, so we have to clear them again.
476 self.clear_all_timers()
477 log.debug('Starting new shutdown timer...')
478- self.timers.add(GLib.timeout_add_seconds(30, self.terminate))
479+ self.timers.add(GLib.timeout_add_seconds(self.timeout, self.terminate))
480
481 def terminate(self, *ignore):
482 """Exit the dispatcher, but only if there are no active subthreads."""
483@@ -96,7 +99,8 @@
484 bus_name = dbus.service.BusName(DBUS_INTERFACE, bus=self.bus)
485 super().__init__(bus_name, self.__dbus_object_path__)
486 self.mainloop = mainloop
487- self.account_manager = AccountManager()
488+
489+ self.accounts = find_accounts()
490
491 self._unread_count = 0
492 self.menu_manager = MenuManager(self.Refresh, self.mainloop.quit)
493@@ -119,12 +123,9 @@
494 # account.protocol() starts a new thread and then returns
495 # immediately, so there is no delay or blocking during the
496 # execution of this method.
497- for account in self.account_manager.get_all():
498- try:
499+ for account in self.accounts.values():
500+ with ignored(NotImplementedError):
501 account.protocol('receive')
502- except NotImplementedError:
503- # If a protocol doesn't support receive then ignore it.
504- pass
505
506 @exit_after_idle
507 @dbus.service.method(DBUS_INTERFACE)
508@@ -163,25 +164,23 @@
509 service.Do('list', '6', 'list_id') # Fetch a single list.
510 """
511 if account_id:
512- accounts = [self.account_manager.get(account_id)]
513+ accounts = [self.accounts.get(int(account_id))]
514 if None in accounts:
515 message = 'Could not find account: {}'.format(account_id)
516 failure(message)
517 log.error(message)
518 return
519 else:
520- accounts = list(self.account_manager.get_all())
521+ accounts = list(self.accounts.values())
522
523 called = False
524 for account in accounts:
525 log.debug('{}: {} {}'.format(account.id, action, arg))
526 args = (action, arg) if arg else (action,)
527- try:
528+ # Not all accounts are expected to implement every action.
529+ with ignored(NotImplementedError):
530 account.protocol(*args, success=success, failure=failure)
531 called = True
532- except NotImplementedError:
533- # Not all accounts are expected to implement every action.
534- pass
535 if not called:
536 failure('No accounts supporting {} found.'.format(action))
537
538@@ -205,7 +204,7 @@
539 service.SendMessage('Your message')
540 """
541 sent = False
542- for account in self.account_manager.get_all():
543+ for account in self.accounts.values():
544 if account.send_enabled:
545 sent = True
546 log.debug(
547@@ -240,7 +239,7 @@
548 service.SendReply('6', '34245645347345626', 'Your reply')
549 """
550 log.debug('Replying to {}, {}'.format(account_id, message_id))
551- account = self.account_manager.get(account_id)
552+ account = self.accounts.get(int(account_id))
553 if account is not None:
554 account.protocol(
555 'send_thread',
556@@ -298,7 +297,7 @@
557 free to ignore error conditions at your peril.
558 """
559 log.debug('Uploading {} to {}'.format(uri, account_id))
560- account = self.account_manager.get(account_id)
561+ account = self.accounts.get(int(account_id))
562 if account is not None:
563 account.protocol(
564 'upload',
565@@ -329,10 +328,11 @@
566
567 @exit_after_idle
568 @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s')
569- def URLShorten(self, url):
570- """Shorten a URL.
571+ def URLShorten(self, message):
572+ """Shorten all the URLs in a message.
573
574- Takes a url as a string and returns a shortened url as a string.
575+ Takes a message as a string, and returns the message with all
576+ it's URLs shortened.
577
578 example:
579 import dbus
580@@ -343,17 +343,10 @@
581 short_url = service.URLShorten(url)
582 """
583 service_name = self.settings.get_string('urlshorter')
584- log.info('Shortening URL {} with {}'.format(url, service_name))
585- if (lookup.is_shortened(url) or
586- not self.settings.get_boolean('shorten-urls')):
587- # It's already shortened, or the preference is not set.
588- return url
589- service = lookup.lookup(service_name)
590- try:
591- return service.shorten(url)
592- except Exception:
593- log.exception('URL shortening class: {}'.format(service))
594- return url
595+ log.info('Shortening with {}'.format(service_name))
596+ if not self.settings.get_boolean('shorten-urls'):
597+ return message
598+ return Short(service_name).sub(message)
599
600 @exit_after_idle
601 @dbus.service.method(DBUS_INTERFACE)
602
603=== removed directory 'friends/shorteners'
604=== removed file 'friends/shorteners/__init__.py'
605=== removed file 'friends/shorteners/base.py'
606--- friends/shorteners/base.py 2013-03-29 01:50:16 +0000
607+++ friends/shorteners/base.py 1970-01-01 00:00:00 +0000
608@@ -1,35 +0,0 @@
609-# friends-dispatcher -- send & receive messages from any social network
610-# Copyright (C) 2012 Canonical Ltd
611-#
612-# This program is free software: you can redistribute it and/or modify
613-# it under the terms of the GNU General Public License as published by
614-# the Free Software Foundation, version 3 of the License.
615-#
616-# This program is distributed in the hope that it will be useful,
617-# but WITHOUT ANY WARRANTY; without even the implied warranty of
618-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
619-# GNU General Public License for more details.
620-#
621-# You should have received a copy of the GNU General Public License
622-# along with this program. If not, see <http://www.gnu.org/licenses/>.
623-
624-"""Convenient base class for URL shorteners."""
625-
626-__all__ = [
627- 'ShortenerBase',
628- ]
629-
630-
631-from urllib.parse import quote
632-
633-from friends.utils.http import Downloader
634-
635-
636-class ShortenerBase:
637- URL_TEMPLATE = None
638- name = None
639- fqdn = None
640-
641- def shorten(self, url):
642- return Downloader(
643- self.URL_TEMPLATE.format(quote(url, safe=''))).get_string().rstrip()
644
645=== removed file 'friends/shorteners/isgd.py'
646--- friends/shorteners/isgd.py 2013-02-13 02:05:37 +0000
647+++ friends/shorteners/isgd.py 1970-01-01 00:00:00 +0000
648@@ -1,32 +0,0 @@
649-# friends-dispatcher -- send & receive messages from any social network
650-# Copyright (C) 2012 Canonical Ltd
651-#
652-# This program is free software: you can redistribute it and/or modify
653-# it under the terms of the GNU General Public License as published by
654-# the Free Software Foundation, version 3 of the License.
655-#
656-# This program is distributed in the hope that it will be useful,
657-# but WITHOUT ANY WARRANTY; without even the implied warranty of
658-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
659-# GNU General Public License for more details.
660-#
661-# You should have received a copy of the GNU General Public License
662-# along with this program. If not, see <http://www.gnu.org/licenses/>.
663-
664-"""is.gd URL shortener for Friends
665-
666-macno (Michele Azzolari) - 02/13/2008
667-"""
668-
669-__all__ = [
670- 'URLShortener',
671- ]
672-
673-
674-from friends.shorteners.base import ShortenerBase
675-
676-
677-class URLShortener(ShortenerBase):
678- URL_TEMPLATE = 'http://is.gd/api.php?longurl={}'
679- fqdn = 'http://is.gd'
680- name = 'is.gd'
681
682=== removed file 'friends/shorteners/linkeecom.py'
683--- friends/shorteners/linkeecom.py 2013-02-13 02:05:37 +0000
684+++ friends/shorteners/linkeecom.py 1970-01-01 00:00:00 +0000
685@@ -1,29 +0,0 @@
686-# friends-dispatcher -- send & receive messages from any social network
687-# Copyright (C) 2013 Canonical Ltd
688-#
689-# This program is free software: you can redistribute it and/or modify
690-# it under the terms of the GNU General Public License as published by
691-# the Free Software Foundation, version 3 of the License.
692-#
693-# This program is distributed in the hope that it will be useful,
694-# but WITHOUT ANY WARRANTY; without even the implied warranty of
695-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
696-# GNU General Public License for more details.
697-#
698-# You should have received a copy of the GNU General Public License
699-# along with this program. If not, see <http://www.gnu.org/licenses/>.
700-
701-"""linkee.com URL shortener for Friends"""
702-
703-__all__ = [
704- 'URLShortener',
705- ]
706-
707-
708-from friends.shorteners.base import ShortenerBase
709-
710-
711-class URLShortener(ShortenerBase):
712- URL_TEMPLATE = 'http://api.linkee.com/1.0/shorten?format=text&input={}'
713- fqdn = 'http://linkee.com'
714- name = 'linkee.com'
715
716=== removed file 'friends/shorteners/ougd.py'
717--- friends/shorteners/ougd.py 2013-02-13 02:05:37 +0000
718+++ friends/shorteners/ougd.py 1970-01-01 00:00:00 +0000
719@@ -1,29 +0,0 @@
720-# friends-dispatcher -- send & receive messages from any social network
721-# Copyright (C) 2012 Canonical Ltd
722-#
723-# This program is free software: you can redistribute it and/or modify
724-# it under the terms of the GNU General Public License as published by
725-# the Free Software Foundation, version 3 of the License.
726-#
727-# This program is distributed in the hope that it will be useful,
728-# but WITHOUT ANY WARRANTY; without even the implied warranty of
729-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
730-# GNU General Public License for more details.
731-#
732-# You should have received a copy of the GNU General Public License
733-# along with this program. If not, see <http://www.gnu.org/licenses/>.
734-
735-"""ou.gd URL shortener for Friends"""
736-
737-__all__ = [
738- 'URLShortener',
739- ]
740-
741-
742-from friends.shorteners.base import ShortenerBase
743-
744-
745-class URLShortener(ShortenerBase):
746- URL_TEMPLATE = 'http://ou.gd/api.php?format=simple&action=shorturl&url={}'
747- fqdn = 'http://ou.gd'
748- name = 'ou.gd'
749
750=== removed file 'friends/shorteners/tinyurlcom.py'
751--- friends/shorteners/tinyurlcom.py 2013-02-13 02:05:37 +0000
752+++ friends/shorteners/tinyurlcom.py 1970-01-01 00:00:00 +0000
753@@ -1,32 +0,0 @@
754-# friends-dispatcher -- send & receive messages from any social network
755-# Copyright (C) 2012 Canonical Ltd
756-#
757-# This program is free software: you can redistribute it and/or modify
758-# it under the terms of the GNU General Public License as published by
759-# the Free Software Foundation, version 3 of the License.
760-#
761-# This program is distributed in the hope that it will be useful,
762-# but WITHOUT ANY WARRANTY; without even the implied warranty of
763-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
764-# GNU General Public License for more details.
765-#
766-# You should have received a copy of the GNU General Public License
767-# along with this program. If not, see <http://www.gnu.org/licenses/>.
768-
769-"""TinyURL.com URL shortener for Friends
770-
771-macno (Michele Azzolari) - 02/13/2008
772-"""
773-
774-__all__ = [
775- 'URLShortener',
776- ]
777-
778-
779-from friends.shorteners.base import ShortenerBase
780-
781-
782-class URLShortener(ShortenerBase):
783- URL_TEMPLATE = 'http://tinyurl.com/api-create.php?url={}'
784- fqdn = 'http://tinyurl.com'
785- name = 'tinyurl.com'
786
787=== added file 'friends/tests/data/durlme.dat'
788--- friends/tests/data/durlme.dat 1970-01-01 00:00:00 +0000
789+++ friends/tests/data/durlme.dat 2013-04-16 17:53:25 +0000
790@@ -0,0 +1,6 @@
791+{
792+ "longUrl":"http://www.yahoo.com/somepath",
793+ "status":"ok",
794+ "shortUrl":"http://durl.me/5o",
795+ "key":"5o"
796+}
797
798=== added file 'friends/tests/data/instagram-full.dat'
799--- friends/tests/data/instagram-full.dat 1970-01-01 00:00:00 +0000
800+++ friends/tests/data/instagram-full.dat 2013-04-16 17:53:25 +0000
801@@ -0,0 +1,473 @@
802+{
803+ "pagination": {
804+ "next_url": "https://api.instagram.com/v1/users/self/feed?access_token=4abc&max_id=431429392441037546_46931811",
805+ "next_max_id": "431429392441037546_46931811"
806+ },
807+ "meta": {
808+ "code": 200
809+ },
810+ "data": [
811+ {
812+ "attribution": null,
813+ "tags": [],
814+ "type": "image",
815+ "location": null,
816+ "comments": {
817+ "count": 1,
818+ "data": [
819+ {
820+ "created_time": "1365680633",
821+ "text": "Wtf is that from?",
822+ "from": {
823+ "username": "ellllliottttt",
824+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_13811917_75sq_1322106636.jpg",
825+ "id": "13811917",
826+ "full_name": "Elliott Markowitz"
827+ },
828+ "id": "431682899841631793"
829+ }
830+ ]
831+ },
832+ "filter": "Normal",
833+ "created_time": "1365655801",
834+ "link": "http://instagram.com/p/X859raK8fx/",
835+ "likes": {
836+ "count": 8,
837+ "data": [
838+ {
839+ "username": "noahvargass",
840+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_1037856_75sq_1354178959.jpg",
841+ "id": "1037856",
842+ "full_name": "Noah Vargas"
843+ },
844+ {
845+ "username": "deniboring",
846+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_16828740_75sq_1345748703.jpg",
847+ "id": "16828740",
848+ "full_name": "deniboring"
849+ },
850+ {
851+ "username": "gabriel_cordero",
852+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_185127499_75sq_1340358021.jpg",
853+ "id": "185127499",
854+ "full_name": "cheeseburger pocket"
855+ },
856+ {
857+ "username": "inmyheadache",
858+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_22645548_75sq_1343529900.jpg",
859+ "id": "22645548",
860+ "full_name": "Tim F"
861+ },
862+ {
863+ "username": "ellllliottttt",
864+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_13811917_75sq_1322106636.jpg",
865+ "id": "13811917",
866+ "full_name": "Elliott Markowitz"
867+ },
868+ {
869+ "username": "eyesofsatan",
870+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_2526196_75sq_1352254863.jpg",
871+ "id": "2526196",
872+ "full_name": "Christopher Hansell"
873+ },
874+ {
875+ "username": "lakeswimming",
876+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_3610143_75sq_1363648525.jpg",
877+ "id": "3610143",
878+ "full_name": "lakeswimming"
879+ },
880+ {
881+ "username": "jtesnakis",
882+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_214679263_75sq_1346185198.jpg",
883+ "id": "214679263",
884+ "full_name": "Jonathan"
885+ }
886+ ]
887+ },
888+ "images": {
889+ "low_resolution": {
890+ "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_6.jpg",
891+ "width": 306,
892+ "height": 306
893+ },
894+ "thumbnail": {
895+ "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_5.jpg",
896+ "width": 150,
897+ "height": 150
898+ },
899+ "standard_resolution": {
900+ "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_7.jpg",
901+ "width": 612,
902+ "height": 612
903+ }
904+ },
905+ "caption": null,
906+ "user_has_liked": false,
907+ "id": "431474591469914097_223207800",
908+ "user": {
909+ "username": "joshwolp",
910+ "website": "",
911+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_223207800_75sq_1347753109.jpg",
912+ "full_name": "Josh",
913+ "bio": "",
914+ "id": "223207800"
915+ }
916+ },
917+ {
918+ "attribution": null,
919+ "tags": [
920+ "nofilter"
921+ ],
922+ "type": "image",
923+ "location": {
924+ "latitude": 40.702485554,
925+ "longitude": -73.929230548
926+ },
927+ "comments": {
928+ "count": 3,
929+ "data": [
930+ {
931+ "created_time": "1365654315",
932+ "text": "I remember pushing that little guy of the swings a few times....",
933+ "from": {
934+ "username": "squidneylol",
935+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_5917696_75sq_1336705905.jpg",
936+ "id": "5917696",
937+ "full_name": "Syd"
938+ },
939+ "id": "431462132263145102"
940+ },
941+ {
942+ "created_time": "1365665741",
943+ "text": "Stop it!!!",
944+ "from": {
945+ "username": "nightruiner",
946+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31239607_75sq_1358968326.jpg",
947+ "id": "31239607",
948+ "full_name": "meredith gaydosh"
949+ },
950+ "id": "431557973837598336"
951+ },
952+ {
953+ "created_time": "1365691283",
954+ "text": "You know I hate being held.",
955+ "from": {
956+ "username": "piratemaddi",
957+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_191388805_75sq_1341897870.jpg",
958+ "id": "191388805",
959+ "full_name": "piratemaddi"
960+ },
961+ "id": "431772240369143639"
962+ }
963+ ]
964+ },
965+ "filter": "Normal",
966+ "created_time": "1365651440",
967+ "link": "http://instagram.com/p/X8xpYwk82w/",
968+ "likes": {
969+ "count": 17,
970+ "data": [
971+ {
972+ "username": "meaghanlou",
973+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_41007792_75sq_1334443633.jpg",
974+ "id": "41007792",
975+ "full_name": "meaghanlou"
976+ },
977+ {
978+ "username": "shahwiththat",
979+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_40353923_75sq_1361330343.jpg",
980+ "id": "40353923",
981+ "full_name": "Soraya Shah"
982+ },
983+ {
984+ "username": "marsyred",
985+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_216361031_75sq_1346524176.jpg",
986+ "id": "216361031",
987+ "full_name": "marsyred"
988+ },
989+ {
990+ "username": "thewhitewizzard",
991+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_189027880_75sq_1341183134.jpg",
992+ "id": "189027880",
993+ "full_name": "Drew Mack"
994+ },
995+ {
996+ "username": "juddymagee",
997+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_8993099_75sq_1315066258.jpg",
998+ "id": "8993099",
999+ "full_name": "juddymagee"
1000+ },
1001+ {
1002+ "username": "pixxamonster",
1003+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_1672624_75sq_1364673157.jpg",
1004+ "id": "1672624",
1005+ "full_name": "Sonia 👓 + Dita 🐈"
1006+ },
1007+ {
1008+ "username": "tongue_in_cheek",
1009+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_5710964_75sq_1345359909.jpg",
1010+ "id": "5710964",
1011+ "full_name": "merrily."
1012+ },
1013+ {
1014+ "username": "nightruiner",
1015+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31239607_75sq_1358968326.jpg",
1016+ "id": "31239607",
1017+ "full_name": "meredith gaydosh"
1018+ },
1019+ {
1020+ "username": "eliz_mortals",
1021+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_36417303_75sq_1364179217.jpg",
1022+ "id": "36417303",
1023+ "full_name": "eliz_mortals"
1024+ },
1025+ {
1026+ "username": "caranicoletti",
1027+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_14162814_75sq_1359848230.jpg",
1028+ "id": "14162814",
1029+ "full_name": "Cara Nicoletti"
1030+ }
1031+ ]
1032+ },
1033+ "images": {
1034+ "low_resolution": {
1035+ "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_6.jpg",
1036+ "width": 306,
1037+ "height": 306
1038+ },
1039+ "thumbnail": {
1040+ "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_5.jpg",
1041+ "width": 150,
1042+ "height": 150
1043+ },
1044+ "standard_resolution": {
1045+ "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_7.jpg",
1046+ "width": 612,
1047+ "height": 612
1048+ }
1049+ },
1050+ "caption": {
1051+ "created_time": "1365651465",
1052+ "text": "National siblings day @piratemaddi ? You mustn't've known. #nofilter",
1053+ "from": {
1054+ "username": "what_a_handsome_boy",
1055+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_5891266_75sq_1345460082.jpg",
1056+ "id": "5891266",
1057+ "full_name": "Fence!"
1058+ },
1059+ "id": "431438224520630210"
1060+ },
1061+ "user_has_liked": false,
1062+ "id": "431438012683111856_5891266",
1063+ "user": {
1064+ "username": "what_a_handsome_boy",
1065+ "website": "",
1066+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_5891266_75sq_1345460082.jpg",
1067+ "full_name": "Fence!",
1068+ "bio": "",
1069+ "id": "5891266"
1070+ }
1071+ },
1072+ {
1073+ "attribution": null,
1074+ "tags": [
1075+ "canada",
1076+ "iphone",
1077+ "punklife",
1078+ "acap",
1079+ "doom",
1080+ "crust"
1081+ ],
1082+ "type": "image",
1083+ "location": null,
1084+ "comments": {
1085+ "count": 7,
1086+ "data": [
1087+ {
1088+ "created_time": "1365651434",
1089+ "text": "I have not seen him for a minute, he is a great guy!",
1090+ "from": {
1091+ "username": "rocktoberblood",
1092+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_210506849_75sq_1355190976.jpg",
1093+ "id": "210506849",
1094+ "full_name": "Nate Phillips"
1095+ },
1096+ "id": "431437959929388541"
1097+ },
1098+ {
1099+ "created_time": "1365652422",
1100+ "text": "Saw him puke in the elevator at the Hilton when Doom played chaos.",
1101+ "from": {
1102+ "username": "occult_obsession",
1103+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_6080835_75sq_1364154159.jpg",
1104+ "id": "6080835",
1105+ "full_name": "Evan Vellela"
1106+ },
1107+ "id": "431446251162427175"
1108+ },
1109+ {
1110+ "created_time": "1365652900",
1111+ "text": "Lol ^ who DIDN'T do this at chaos",
1112+ "from": {
1113+ "username": "laurababbili",
1114+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_1238692_75sq_1361065720.jpg",
1115+ "id": "1238692",
1116+ "full_name": "Laura Babbili"
1117+ },
1118+ "id": "431450261906907041"
1119+ },
1120+ {
1121+ "created_time": "1365655310",
1122+ "text": "I tried to kiss the singer of cocksparrer in that elevator.",
1123+ "from": {
1124+ "username": "gregdaly",
1125+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg",
1126+ "id": "31133446",
1127+ "full_name": "greg daly"
1128+ },
1129+ "id": "431470473175752176"
1130+ },
1131+ {
1132+ "created_time": "1365655687",
1133+ "text": "Leant that dude a 900 Marshall a time ago! Love that dude!!",
1134+ "from": {
1135+ "username": "nickatnightengale",
1136+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_29366372_75sq_1354241971.jpg",
1137+ "id": "29366372",
1138+ "full_name": "Nick Poulos"
1139+ },
1140+ "id": "431473639539727956"
1141+ },
1142+ {
1143+ "created_time": "1365658654",
1144+ "text": "Hahaha!!",
1145+ "from": {
1146+ "username": "jeremyhush",
1147+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_43750226_75sq_1335024423.jpg",
1148+ "id": "43750226",
1149+ "full_name": "Jeremy Hush"
1150+ },
1151+ "id": "431498525729480083"
1152+ },
1153+ {
1154+ "created_time": "1365685226",
1155+ "text": "There's an app for that",
1156+ "from": {
1157+ "username": "liam_wilson",
1158+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_4357248_75sq_1327009309.jpg",
1159+ "id": "4357248",
1160+ "full_name": "Liam Wilson"
1161+ },
1162+ "id": "431721432141388885"
1163+ }
1164+ ]
1165+ },
1166+ "filter": "X-Pro II",
1167+ "created_time": "1365650801",
1168+ "link": "http://instagram.com/p/X8wbTQtd3Y/",
1169+ "likes": {
1170+ "count": 36,
1171+ "data": [
1172+ {
1173+ "username": "fatbenatar",
1174+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_280136224_75sq_1357073366.jpg",
1175+ "id": "280136224",
1176+ "full_name": "China Oxendine"
1177+ },
1178+ {
1179+ "username": "tejleo",
1180+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_10403332_75sq_1354058520.jpg",
1181+ "id": "10403332",
1182+ "full_name": "tejleo"
1183+ },
1184+ {
1185+ "username": "tonyromeo13",
1186+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_255933637_75sq_1360272947.jpg",
1187+ "id": "255933637",
1188+ "full_name": "Tony Romeo"
1189+ },
1190+ {
1191+ "username": "libraryrat4",
1192+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_17715046_75sq_1325381597.jpg",
1193+ "id": "17715046",
1194+ "full_name": "libraryrat4"
1195+ },
1196+ {
1197+ "username": "boredwithapathy",
1198+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_174679368_75sq_1338500455.jpg",
1199+ "id": "174679368",
1200+ "full_name": "Lisa McCarthy"
1201+ },
1202+ {
1203+ "username": "justinpittney",
1204+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_10976940_75sq_1361306615.jpg",
1205+ "id": "10976940",
1206+ "full_name": "Justin Pittney"
1207+ },
1208+ {
1209+ "username": "nuclearhell",
1210+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_212702314_75sq_1361642319.jpg",
1211+ "id": "212702314",
1212+ "full_name": "Rachel Whittaker"
1213+ },
1214+ {
1215+ "username": "gregelk",
1216+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_240084204_75sq_1359223473.jpg",
1217+ "id": "240084204",
1218+ "full_name": "Greg Elk"
1219+ },
1220+ {
1221+ "username": "bradhasher",
1222+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_283278325_75sq_1357514041.jpg",
1223+ "id": "283278325",
1224+ "full_name": "Brett Ellingson"
1225+ },
1226+ {
1227+ "username": "zaimlmzim",
1228+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_180060277_75sq_1363118292.jpg",
1229+ "id": "180060277",
1230+ "full_name": "zaimlmzim"
1231+ }
1232+ ]
1233+ },
1234+ "images": {
1235+ "low_resolution": {
1236+ "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_6.jpg",
1237+ "width": 306,
1238+ "height": 306
1239+ },
1240+ "thumbnail": {
1241+ "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_5.jpg",
1242+ "width": 150,
1243+ "height": 150
1244+ },
1245+ "standard_resolution": {
1246+ "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_7.jpg",
1247+ "width": 612,
1248+ "height": 612
1249+ }
1250+ },
1251+ "caption": {
1252+ "created_time": "1365650915",
1253+ "text": "This is my friend Scoot. He plays bass in #doom and is a mega rock star in newfoundland. Here you can see him signing an autograph on an iphone. #crust #punklife #iphone #canada #acap",
1254+ "from": {
1255+ "username": "gregdaly",
1256+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg",
1257+ "id": "31133446",
1258+ "full_name": "greg daly"
1259+ },
1260+ "id": "431433605218426202"
1261+ },
1262+ "user_has_liked": false,
1263+ "id": "431432646660578776_31133446",
1264+ "user": {
1265+ "username": "gregdaly",
1266+ "website": "",
1267+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg",
1268+ "full_name": "greg daly",
1269+ "bio": "",
1270+ "id": "31133446"
1271+ }
1272+ }
1273+ ]
1274+}
1275
1276=== added file 'friends/tests/data/instagram-login.dat'
1277--- friends/tests/data/instagram-login.dat 1970-01-01 00:00:00 +0000
1278+++ friends/tests/data/instagram-login.dat 2013-04-16 17:53:25 +0000
1279@@ -0,0 +1,18 @@
1280+{
1281+ "meta": {
1282+ "code": 200
1283+ },
1284+ "data": {
1285+ "username": "bpersons",
1286+ "bio": "",
1287+ "website": "",
1288+ "profile_picture": "http://images.ak.instagram.com/profiles/anonymousUser.jpg",
1289+ "full_name": "Bart Person",
1290+ "counts": {
1291+ "media": 174,
1292+ "followed_by": 100,
1293+ "follows": 503
1294+ },
1295+ "id": "801"
1296+ }
1297+}
1298
1299=== modified file 'friends/tests/mocks.py'
1300--- friends/tests/mocks.py 2013-04-12 22:06:57 +0000
1301+++ friends/tests/mocks.py 2013-04-16 17:53:25 +0000
1302@@ -21,7 +21,6 @@
1303 'FakeAccount',
1304 'FakeSoupMessage',
1305 'LogMock',
1306- 'SettingsIterMock',
1307 'mock',
1308 ]
1309
1310@@ -37,6 +36,7 @@
1311 from logging.handlers import QueueHandler
1312 from pkg_resources import resource_listdir, resource_string
1313 from queue import Empty, Queue
1314+from unittest import mock
1315 from urllib.parse import urlsplit
1316 from gi.repository import Dee
1317
1318@@ -52,13 +52,6 @@
1319 from friends.utils.logging import LOG_FORMAT
1320
1321
1322-try:
1323- # Python 3.3
1324- from unittest import mock
1325-except ImportError:
1326- import mock
1327-
1328-
1329 NEWLINE = '\n'
1330
1331
1332@@ -107,16 +100,21 @@
1333
1334
1335 class FakeAuth:
1336- id = 'fakeauth id'
1337- method = 'fakeauth method'
1338- parameters = {'ConsumerKey': 'fake', 'ConsumerSecret': 'alsofake'}
1339- mechanism = 'fakeauth mechanism'
1340+ get_credentials_id = lambda *ignore: 'fakeauth id'
1341+ get_method = lambda *ignore: 'fakeauth method'
1342+ get_mechanism = lambda *ignore: 'fakeauth mechanism'
1343+ get_parameters = lambda *ignore: {
1344+ 'ConsumerKey': 'fake',
1345+ 'ConsumerSecret': 'alsofake',
1346+ }
1347
1348
1349 class FakeAccount:
1350 """A fake account object for testing purposes."""
1351
1352 def __init__(self, service=None, account_id=88):
1353+ self.consumer_secret = 'secret'
1354+ self.consumer_key = 'consume'
1355 self.access_token = None
1356 self.secret_token = None
1357 self.avatar_url = None
1358@@ -188,23 +186,6 @@
1359 return self
1360
1361
1362-class SettingsIterMock:
1363- """Mimic the weird libaccounts AgAccountSettingIter semantics.
1364-
1365- The default Python mapping of this object does not follow standard Python
1366- iterator semantics.
1367- """
1368-
1369- def __init__(self):
1370- self.items = [(True, 'send_enabled', True)]
1371-
1372- def next(self):
1373- if self.items:
1374- return self.items.pop()
1375- else:
1376- return (False, None, None)
1377-
1378-
1379 class LogMock:
1380 """A mocker for capturing logging output in protocol classes.
1381
1382
1383=== modified file 'friends/tests/test_account.py'
1384--- friends/tests/test_account.py 2013-03-14 19:14:03 +0000
1385+++ friends/tests/test_account.py 2013-04-16 17:53:25 +0000
1386@@ -17,7 +17,6 @@
1387
1388 __all__ = [
1389 'TestAccount',
1390- 'TestAccountManager',
1391 ]
1392
1393
1394@@ -25,15 +24,16 @@
1395
1396 from friends.errors import UnsupportedProtocolError
1397 from friends.protocols.flickr import Flickr
1398-from friends.tests.mocks import FakeAccount, LogMock, SettingsIterMock
1399-from friends.tests.mocks import TestModel, mock
1400-from friends.utils.account import Account, AccountManager
1401+from friends.tests.mocks import FakeAccount, LogMock
1402+from friends.tests.mocks import TestModel, LogMock, mock
1403+from friends.utils.account import Account, _find_accounts_uoa
1404
1405
1406 class TestAccount(unittest.TestCase):
1407 """Test Account class."""
1408
1409 def setUp(self):
1410+ self.log_mock = LogMock('friends.utils.account')
1411 def connect_side_effect(signal, callback, account):
1412 # The account service provides a .connect method that connects a
1413 # signal to a callback. We have to mock a side effect into the
1414@@ -49,10 +49,12 @@
1415 'get_credentials_id.return_value': 'fake credentials',
1416 'get_method.return_value': 'fake method',
1417 'get_mechanism.return_value': 'fake mechanism',
1418- 'get_parameters.return_value': 'fake parameters',
1419+ 'get_parameters.return_value': {
1420+ 'ConsumerKey': 'fake_key',
1421+ 'ConsumerSecret': 'fake_secret'},
1422 }),
1423 'get_account.return_value': mock.Mock(**{
1424- 'get_settings_iter.return_value': SettingsIterMock(),
1425+ 'get_settings_dict.return_value': dict(send_enabled=True),
1426 'id': 'fake_id',
1427 'get_provider_name.return_value': 'flickr',
1428 }),
1429@@ -63,17 +65,21 @@
1430 })
1431 self.account = Account(self.account_service)
1432
1433+ def tearDown(self):
1434+ self.log_mock.stop()
1435+
1436 def test_account_auth(self):
1437 # Test that the constructor initializes the 'auth' attribute.
1438 auth = self.account.auth
1439- self.assertEqual(auth.id, 'fake credentials')
1440- self.assertEqual(auth.method, 'fake method')
1441- self.assertEqual(auth.mechanism, 'fake mechanism')
1442- self.assertEqual(auth.parameters, 'fake parameters')
1443+ self.assertEqual(auth.get_credentials_id(), 'fake credentials')
1444+ self.assertEqual(auth.get_method(), 'fake method')
1445+ self.assertEqual(auth.get_mechanism(), 'fake mechanism')
1446+ self.assertEqual(auth.get_parameters(),
1447+ dict(ConsumerKey='fake_key',
1448+ ConsumerSecret='fake_secret'))
1449
1450 def test_account_id(self):
1451 self.assertEqual(self.account.id, 'fake_id')
1452- self.assertEqual(self.account.protocol_name, 'flickr')
1453
1454 def test_account_service(self):
1455 # The protocol attribute refers directly to the protocol used.
1456@@ -92,24 +98,23 @@
1457 # constructor. Test that it has the expected original key value.
1458 self.assertEqual(self.account.send_enabled, True)
1459
1460- def test_iter_filter(self):
1461- # The get_settings_iter() filters everything that doesn't start with
1462+ def test_dict_filter(self):
1463+ # The get_settings_dict() filters everything that doesn't start with
1464 # 'friends/'
1465- self._callback_account.get_settings_iter.assert_called_with('friends/')
1466+ self._callback_account.get_settings_dict.assert_called_with('friends/')
1467
1468 def test_on_account_changed_signal(self):
1469 # Test that when the account changes, and a 'changed' signal is
1470 # received, the callback is called and the account is updated.
1471 #
1472 # Start by simulating a change in the account service.
1473- other_iter = SettingsIterMock()
1474- other_iter.items = [
1475- (True, 'send_enabled', False),
1476- (True, 'bee', 'two'),
1477- (True, 'cat', 'three'),
1478- ]
1479- iter = self.account_service.get_account().get_settings_iter
1480- iter.return_value = other_iter
1481+ other_dict = dict(
1482+ send_enabled=False,
1483+ bee='two',
1484+ cat='three',
1485+ )
1486+ adict = self.account_service.get_account().get_settings_dict
1487+ adict.return_value = other_dict
1488 # Check that the signal has been connected.
1489 self.assertEqual(self._callback_signal, 'changed')
1490 # Check that the account is the object we expect it to be.
1491@@ -122,102 +127,18 @@
1492 self.assertFalse(hasattr(self.account, 'bee'))
1493 self.assertFalse(hasattr(self.account, 'cat'))
1494
1495- def test_enabled(self):
1496- # .enabled() just passes through from the account service.
1497- self.account_service.get_enabled.return_value = True
1498- self.assertTrue(self.account.enabled)
1499- self.account_service.get_enabled.return_value = False
1500- self.assertFalse(self.account.enabled)
1501-
1502- def test_equal(self):
1503- # Two accounts are equal if their account services are equal.
1504- other = Account(self.account_service)
1505- self.assertEqual(self.account, other)
1506- assert not self.account == None
1507-
1508- def test_unequal(self):
1509- # Two accounts are unequal if their account services are unequal. The
1510- # other mock service has to at least support the basic required API.
1511- other = Account(mock.Mock(**{
1512- 'get_account.return_value': mock.Mock(**{
1513- 'get_settings_iter.return_value': SettingsIterMock(),
1514- # It's okay if the provider names are the same; the test
1515- # is for whether the account services are the same or not,
1516- # and in this test, they'll be different mock instances.
1517- 'get_provider_name.return_value': 'flickr',
1518- }),
1519- }))
1520- self.assertNotEqual(self.account, other)
1521- assert self.account != None
1522-
1523-
1524-accounts_manager = mock.Mock()
1525-accounts_manager.new_for_service_type(
1526- 'microblogging').get_enabled_account_services.return_value = []
1527-
1528-
1529-@mock.patch('gi.repository.Accounts.Manager', accounts_manager)
1530-@mock.patch('friends.utils.account.Account', FakeAccount)
1531-class TestAccountManager(unittest.TestCase):
1532- """Test the AccountManager API."""
1533-
1534- def setUp(self):
1535- TestModel.clear()
1536- self.account_service = mock.Mock()
1537-
1538+ @mock.patch('friends.utils.account.manager')
1539+ @mock.patch('friends.utils.account.Account')
1540 @mock.patch('friends.utils.account.Accounts')
1541- def test_get_service(self, accounts_mock):
1542- manager = AccountManager()
1543- manager_mock = mock.Mock()
1544- account_mock = mock.Mock()
1545- service_mock = mock.Mock()
1546- manager_mock.get_account.return_value = account_mock
1547- account_mock.list_services.return_value = [service_mock]
1548- account_service_mock = accounts_mock.AccountService.new(account_mock,
1549- service_mock)
1550- account_service_mock.get_service(
1551- ).get_display_name().lower.return_value = 'protocol'
1552-
1553- service = manager._get_service(manager_mock, 10)
1554-
1555- manager_mock.get_account.assert_called_once_with(10)
1556- account_mock.list_services.assert_called_once_with()
1557- accounts_mock.AccountService.new.assert_called_with(account_mock,
1558- service_mock)
1559-
1560- def test_account_manager_add_new_account(self):
1561- # Explicitly adding a new account puts the account's global_id into
1562- # the account manager's mapping.
1563- manager = AccountManager()
1564- manager._add_new_account(self.account_service)
1565- self.assertIn(88, manager._accounts)
1566-
1567- def test_account_manager_enabled_event(self):
1568- manager = AccountManager()
1569- manager._get_service = mock.Mock()
1570- manager._get_service.return_value = mock.Mock()
1571- manager._add_new_account = mock.Mock()
1572- manager._add_new_account.return_value = account = mock.Mock()
1573- manager._on_enabled_event(accounts_manager, 2)
1574- account.protocol.assert_called_once_with('receive')
1575-
1576-
1577-@mock.patch('gi.repository.Accounts.Manager', accounts_manager)
1578-class TestAccountManagerRealAccount(unittest.TestCase):
1579- """Test of the AccountManager API requiring the real Account class.
1580-
1581- You'll need to guarantee other mocks are in place such that the real
1582- accounts are not touched.
1583- """
1584- def setUp(self):
1585- self.account_service = mock.Mock()
1586-
1587- def test_account_manager_add_new_account_unsupported(self):
1588- fake_account = self.account_service.get_account()
1589- fake_account.get_provider_name.return_value = 'no service'
1590- manager = AccountManager()
1591- with LogMock('friends.utils.account') as log_mock:
1592- manager._add_new_account(self.account_service)
1593- log_contents = log_mock.empty(trim=False)
1594- self.assertNotIn('no service', manager._accounts)
1595- self.assertEqual(log_contents, 'Unsupported protocol: no service\n')
1596+ def test_find_accounts(self, accts, acct, manager):
1597+ service = mock.Mock()
1598+ get_enabled = manager.get_enabled_account_services
1599+ get_enabled.return_value = [service]
1600+ manager.reset_mock()
1601+ accounts = _find_accounts_uoa()
1602+ get_enabled.assert_called_once_with()
1603+ acct.assert_called_once_with(service)
1604+ self.assertEqual(accounts, {acct().id: acct()})
1605+ self.assertEqual(self.log_mock.empty(),
1606+ 'Flickr (fake_id) got send_enabled: True\n'
1607+ 'Accounts found: 1\n')
1608
1609=== modified file 'friends/tests/test_authentication.py'
1610--- friends/tests/test_authentication.py 2013-02-05 01:11:35 +0000
1611+++ friends/tests/test_authentication.py 2013-04-16 17:53:25 +0000
1612@@ -27,7 +27,7 @@
1613 import unittest
1614
1615 from friends.utils.authentication import Authentication
1616-from friends.tests.mocks import FakeAccount, mock
1617+from friends.tests.mocks import FakeAccount, LogMock, mock
1618 from friends.errors import AuthorizationError
1619
1620
1621@@ -43,7 +43,11 @@
1622 # error, and user_data arguments. We'll use the parameters
1623 # argument as a way to specify whether an error occurred during
1624 # authentication or not.
1625- callback(None, self.results, parameters, None)
1626+ callback(
1627+ None,
1628+ self.results,
1629+ parameters if hasattr(parameters, 'message') else None,
1630+ None)
1631
1632
1633 class FakeSignon:
1634@@ -56,59 +60,52 @@
1635 results = dict(NoAccessToken='fail')
1636
1637
1638-class Logger:
1639- def __init__(self):
1640- self.debug_messages = []
1641- self.error_messages = []
1642-
1643- def debug(self, message, *args):
1644- self.debug_messages.append(message.format(*args))
1645-
1646- def error(self, message, *args):
1647- self.error_messages.append(message.format(*args))
1648-
1649- reset = __init__
1650-
1651-
1652-logger = Logger()
1653-
1654-
1655 class TestAuthentication(unittest.TestCase):
1656 """Test authentication."""
1657
1658 def setUp(self):
1659+ self.log_mock = LogMock('friends.utils.authentication')
1660 self.account = FakeAccount()
1661- self.account.auth.id = 'my id'
1662- self.account.auth.method = 'some method'
1663- self.account.auth.parameters = 'change me'
1664- self.account.auth.mechanism = ['whatever']
1665- logger.reset()
1666-
1667- @mock.patch('friends.utils.authentication.log', logger)
1668+ self.account.auth.get_credentials_id = lambda *ignore: 'my id'
1669+ self.account.auth.get_method = lambda *ignore: 'some method'
1670+ self.account.auth.get_parameters = lambda *ignore: 'change me'
1671+ self.account.auth.get_mechanism = lambda *ignore: 'whatever'
1672+
1673+ def tearDown(self):
1674+ self.log_mock.stop()
1675+
1676 @mock.patch('friends.utils.authentication.Signon', FakeSignon)
1677- def test_successful_login(self):
1678+ @mock.patch('friends.utils.authentication.manager')
1679+ @mock.patch('friends.utils.authentication.Accounts')
1680+ def test_successful_login(self, accounts, manager):
1681+ manager.get_account().list_services.return_value = ['foo']
1682 # Prevent an error in the callback.
1683- self.account.auth.parameters = False
1684- authenticator = Authentication(self.account)
1685+ accounts.AccountService.new().get_auth_data(
1686+ ).get_parameters.return_value = False
1687+ authenticator = Authentication(self.account.id)
1688 reply = authenticator.login()
1689 self.assertEqual(reply, dict(AccessToken='auth reply'))
1690- self.assertEqual(logger.debug_messages, ['Login completed'])
1691- self.assertEqual(logger.error_messages, [])
1692+ self.assertEqual(self.log_mock.empty(), 'Login completed\n')
1693
1694- @mock.patch('friends.utils.authentication.log', logger)
1695 @mock.patch('friends.utils.authentication.Signon', FailingSignon)
1696- def test_missing_access_token(self):
1697+ @mock.patch('friends.utils.authentication.manager')
1698+ @mock.patch('friends.utils.authentication.Accounts')
1699+ def test_missing_access_token(self, accounts, manager):
1700+ manager.get_account().list_services.return_value = ['foo']
1701 # Prevent an error in the callback.
1702- self.account.auth.parameters = False
1703- authenticator = Authentication(self.account)
1704+ self.account.auth.get_parameters = lambda *ignore: False
1705+ authenticator = Authentication(self.account.id)
1706 self.assertRaises(AuthorizationError, authenticator.login)
1707
1708- @mock.patch('friends.utils.authentication.log', logger)
1709 @mock.patch('friends.utils.authentication.Signon', FakeSignon)
1710- def test_failed_login(self):
1711+ @mock.patch('friends.utils.authentication.manager')
1712+ @mock.patch('friends.utils.authentication.Accounts')
1713+ def test_failed_login(self, accounts, manager):
1714 # Trigger an error in the callback.
1715 class Error:
1716 message = 'who are you?'
1717- self.account.auth.parameters = Error
1718- authenticator = Authentication(self.account)
1719+ manager.get_account().list_services.return_value = ['foo']
1720+ accounts.AccountService.new(
1721+ ).get_auth_data().get_parameters.return_value = Error
1722+ authenticator = Authentication(self.account.id)
1723 self.assertRaises(AuthorizationError, authenticator.login)
1724
1725=== modified file 'friends/tests/test_dispatcher.py'
1726--- friends/tests/test_dispatcher.py 2013-04-03 03:47:39 +0000
1727+++ friends/tests/test_dispatcher.py 2013-04-16 17:53:25 +0000
1728@@ -34,17 +34,19 @@
1729 DBusGMainLoop(set_as_default=True)
1730
1731
1732+@mock.patch('friends.service.dispatcher.GLib.timeout_add_seconds',
1733+ mock.Mock(return_value=42))
1734 class TestDispatcher(unittest.TestCase):
1735 """Test the dispatcher's ability to dispatch."""
1736
1737 @mock.patch('dbus.service.BusName')
1738- @mock.patch('friends.service.dispatcher.AccountManager')
1739- @mock.patch('friends.service.dispatcher.Dispatcher.Refresh')
1740+ @mock.patch('friends.service.dispatcher.find_accounts')
1741 @mock.patch('dbus.service.Object.__init__')
1742 def setUp(self, *mocks):
1743 self.log_mock = LogMock('friends.service.dispatcher',
1744 'friends.utils.account')
1745 self.dispatcher = Dispatcher(mock.Mock(), mock.Mock())
1746+ self.dispatcher.accounts = {}
1747
1748 def tearDown(self):
1749 self.log_mock.stop()
1750@@ -53,18 +55,17 @@
1751 def test_refresh(self, threading_mock):
1752 account = mock.Mock()
1753 threading_mock.activeCount.return_value = 1
1754- self.dispatcher.account_manager = mock.Mock()
1755- self.dispatcher.account_manager.get_all.return_value = [account]
1756+ self.dispatcher.accounts = mock.Mock()
1757+ self.dispatcher.accounts.values.return_value = [account]
1758
1759 self.assertIsNone(self.dispatcher.Refresh())
1760
1761- self.dispatcher.account_manager.get_all.assert_called_once_with()
1762+ self.dispatcher.accounts.values.assert_called_once_with()
1763 account.protocol.assert_called_once_with('receive')
1764
1765 self.assertEqual(self.log_mock.empty(),
1766- 'Clearing 1 shutdown timer(s)...\n'
1767+ 'Clearing timer id: 42\n'
1768 'Refresh requested\n'
1769- 'Clearing 0 shutdown timer(s)...\n'
1770 'Starting new shutdown timer...\n')
1771
1772 def test_clear_indicators(self):
1773@@ -75,34 +76,31 @@
1774 def test_do(self):
1775 account = mock.Mock()
1776 account.id = '345'
1777- self.dispatcher.account_manager = mock.Mock()
1778- self.dispatcher.account_manager.get.return_value = account
1779+ self.dispatcher.accounts = mock.Mock()
1780+ self.dispatcher.accounts.get.return_value = account
1781
1782 self.dispatcher.Do('like', '345', '23346356767354626')
1783- self.dispatcher.account_manager.get.assert_called_once_with(
1784- '345')
1785+ self.dispatcher.accounts.get.assert_called_once_with(345)
1786 account.protocol.assert_called_once_with(
1787 'like', '23346356767354626', success=STUB, failure=STUB)
1788
1789 self.assertEqual(self.log_mock.empty(),
1790- 'Clearing 1 shutdown timer(s)...\n'
1791+ 'Clearing timer id: 42\n'
1792 '345: like 23346356767354626\n'
1793- 'Clearing 0 shutdown timer(s)...\n'
1794 'Starting new shutdown timer...\n')
1795
1796 def test_failing_do(self):
1797 account = mock.Mock()
1798- self.dispatcher.account_manager = mock.Mock()
1799- self.dispatcher.account_manager.get.return_value = None
1800+ self.dispatcher.accounts = mock.Mock()
1801+ self.dispatcher.accounts.get.return_value = None
1802
1803 self.dispatcher.Do('unlike', '6', '23346356767354626')
1804- self.dispatcher.account_manager.get.assert_called_once_with('6')
1805+ self.dispatcher.accounts.get.assert_called_once_with(6)
1806 self.assertEqual(account.protocol.call_count, 0)
1807
1808 self.assertEqual(self.log_mock.empty(),
1809- 'Clearing 1 shutdown timer(s)...\n'
1810+ 'Clearing timer id: 42\n'
1811 'Could not find account: 6\n'
1812- 'Clearing 0 shutdown timer(s)...\n'
1813 'Starting new shutdown timer...\n')
1814
1815 def test_send_message(self):
1816@@ -111,15 +109,15 @@
1817 account3 = mock.Mock()
1818 account2.send_enabled = False
1819
1820- self.dispatcher.account_manager = mock.Mock()
1821- self.dispatcher.account_manager.get_all.return_value = [
1822+ self.dispatcher.accounts = mock.Mock()
1823+ self.dispatcher.accounts.values.return_value = [
1824 account1,
1825 account2,
1826 account3,
1827 ]
1828
1829 self.dispatcher.SendMessage('Howdy friends!')
1830- self.dispatcher.account_manager.get_all.assert_called_once_with()
1831+ self.dispatcher.accounts.values.assert_called_once_with()
1832 account1.protocol.assert_called_once_with(
1833 'send', 'Howdy friends!', success=STUB, failure=STUB)
1834 account3.protocol.assert_called_once_with(
1835@@ -128,41 +126,39 @@
1836
1837 def test_send_reply(self):
1838 account = mock.Mock()
1839- self.dispatcher.account_manager = mock.Mock()
1840- self.dispatcher.account_manager.get.return_value = account
1841+ self.dispatcher.accounts = mock.Mock()
1842+ self.dispatcher.accounts.get.return_value = account
1843
1844 self.dispatcher.SendReply('2', 'objid', '[Hilarious Response]')
1845- self.dispatcher.account_manager.get.assert_called_once_with('2')
1846+ self.dispatcher.accounts.get.assert_called_once_with(2)
1847 account.protocol.assert_called_once_with(
1848 'send_thread', 'objid', '[Hilarious Response]',
1849 success=STUB, failure=STUB)
1850
1851 self.assertEqual(self.log_mock.empty(),
1852- 'Clearing 1 shutdown timer(s)...\n'
1853+ 'Clearing timer id: 42\n'
1854 'Replying to 2, objid\n'
1855- 'Clearing 0 shutdown timer(s)...\n'
1856 'Starting new shutdown timer...\n')
1857
1858 def test_send_reply_failed(self):
1859 account = mock.Mock()
1860- self.dispatcher.account_manager = mock.Mock()
1861- self.dispatcher.account_manager.get.return_value = None
1862+ self.dispatcher.accounts = mock.Mock()
1863+ self.dispatcher.accounts.get.return_value = None
1864
1865 self.dispatcher.SendReply('2', 'objid', '[Hilarious Response]')
1866- self.dispatcher.account_manager.get.assert_called_once_with('2')
1867+ self.dispatcher.accounts.get.assert_called_once_with(2)
1868 self.assertEqual(account.protocol.call_count, 0)
1869
1870 self.assertEqual(self.log_mock.empty(),
1871- 'Clearing 1 shutdown timer(s)...\n'
1872+ 'Clearing timer id: 42\n'
1873 'Replying to 2, objid\n'
1874 'Could not find account: 2\n'
1875- 'Clearing 0 shutdown timer(s)...\n'
1876 'Starting new shutdown timer...\n')
1877
1878 def test_upload_async(self):
1879 account = mock.Mock()
1880- self.dispatcher.account_manager = mock.Mock()
1881- self.dispatcher.account_manager.get.return_value = account
1882+ self.dispatcher.accounts = mock.Mock()
1883+ self.dispatcher.accounts.get.return_value = account
1884
1885 success = mock.Mock()
1886 failure = mock.Mock()
1887@@ -172,7 +168,7 @@
1888 'A thousand words',
1889 success=success,
1890 failure=failure)
1891- self.dispatcher.account_manager.get.assert_called_once_with('2')
1892+ self.dispatcher.accounts.get.assert_called_once_with(2)
1893 account.protocol.assert_called_once_with(
1894 'upload',
1895 'file://path/to/image.png',
1896@@ -182,9 +178,8 @@
1897 )
1898
1899 self.assertEqual(self.log_mock.empty(),
1900- 'Clearing 1 shutdown timer(s)...\n'
1901+ 'Clearing timer id: 42\n'
1902 'Uploading file://path/to/image.png to 2\n'
1903- 'Clearing 0 shutdown timer(s)...\n'
1904 'Starting new shutdown timer...\n')
1905
1906 def test_get_features(self):
1907@@ -214,21 +209,19 @@
1908 self.dispatcher.URLShorten('http://tinyurl.com/foo'))
1909
1910 @mock.patch('friends.service.dispatcher.logging')
1911- @mock.patch('friends.service.dispatcher.lookup')
1912- def test_urlshorten(self, lookup_mock, logging_mock):
1913- lookup_mock.is_shortened.return_value = False
1914- lookup_mock.lookup.return_value = mock.Mock()
1915- lookup_mock.lookup.return_value.shorten.return_value = 'short url'
1916+ @mock.patch('friends.service.dispatcher.Short')
1917+ def test_urlshorten(self, short_mock, logging_mock):
1918+ short_mock().sub.return_value = 'short url'
1919+ short_mock.reset_mock()
1920 self.dispatcher.settings.get_string.return_value = 'is.gd'
1921 long_url = 'http://example.com/really/really/long'
1922 self.assertEqual(
1923 self.dispatcher.URLShorten(long_url),
1924 'short url')
1925- lookup_mock.is_shortened.assert_called_once_with(long_url)
1926 self.dispatcher.settings.get_boolean.assert_called_once_with(
1927 'shorten-urls')
1928- lookup_mock.lookup.assert_called_once_with('is.gd')
1929- lookup_mock.lookup.return_value.shorten.assert_called_once_with(
1930+ short_mock.assert_called_once_with('is.gd')
1931+ short_mock.return_value.sub.assert_called_once_with(
1932 long_url)
1933
1934 @mock.patch('friends.service.dispatcher.GLib')
1935@@ -243,6 +236,7 @@
1936
1937 @mock.patch('friends.service.dispatcher.GLib')
1938 def test_manage_timers_set(self, glib):
1939+ glib.timeout_add_seconds.reset_mock()
1940 manager = ManageTimers()
1941 manager.timers = set()
1942 manager.clear_all_timers = mock.Mock()
1943
1944=== modified file 'friends/tests/test_facebook.py'
1945--- friends/tests/test_facebook.py 2013-04-02 21:30:43 +0000
1946+++ friends/tests/test_facebook.py 2013-04-16 17:53:25 +0000
1947@@ -60,11 +60,15 @@
1948 ['contacts', 'delete', 'home', 'like', 'receive', 'search', 'send',
1949 'send_thread', 'unlike', 'upload', 'wall'])
1950
1951+ @mock.patch('friends.utils.authentication.manager')
1952+ @mock.patch('friends.utils.authentication.Accounts')
1953+ @mock.patch('friends.utils.authentication.Authentication.__init__',
1954+ return_value=None)
1955 @mock.patch('friends.utils.authentication.Authentication.login',
1956 return_value=dict(AccessToken='abc'))
1957 @mock.patch('friends.utils.http.Soup.Message',
1958 FakeSoupMessage('friends.tests.data', 'facebook-login.dat'))
1959- def test_successful_login(self, mock):
1960+ def test_successful_login(self, *mocks):
1961 # Test that a successful response from graph.facebook.com returning
1962 # the user's data, sets up the account dict correctly.
1963 self.protocol._login()
1964@@ -72,14 +76,18 @@
1965 self.assertEqual(self.account.user_name, 'Bart Person')
1966 self.assertEqual(self.account.user_id, '801')
1967
1968+ @mock.patch('friends.utils.authentication.manager')
1969+ @mock.patch('friends.utils.authentication.Accounts')
1970 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
1971 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
1972- def test_login_unsuccessful_authentication(self, mock):
1973+ def test_login_unsuccessful_authentication(self, *mocks):
1974 # The user is not already logged in, but the act of logging in fails.
1975 self.assertRaises(AuthorizationError, self.protocol._login)
1976 self.assertIsNone(self.account.access_token)
1977 self.assertIsNone(self.account.user_name)
1978
1979+ @mock.patch('friends.utils.authentication.manager')
1980+ @mock.patch('friends.utils.authentication.Accounts')
1981 @mock.patch('friends.utils.authentication.Authentication.login',
1982 return_value=dict(AccessToken='abc'))
1983 @mock.patch('friends.protocols.facebook.Downloader.get_json',
1984@@ -95,10 +103,7 @@
1985 self.protocol.home,
1986 )
1987 contents = log_mock.empty(trim=False)
1988- self.assertEqual(contents, """\
1989-Logging in to Facebook
1990-Facebook UID: None
1991-""")
1992+ self.assertEqual(contents, 'Logging in to Facebook\n')
1993
1994 @mock.patch('friends.utils.http.Soup.Message',
1995 FakeSoupMessage('friends.tests.data', 'facebook-full.dat'))
1996@@ -237,9 +242,6 @@
1997 def test_home_since_id(self, *mocks):
1998 self.account.access_token = 'access'
1999 self.account.secret_token = 'secret'
2000- self.account.auth.parameters = dict(
2001- ConsumerKey='key',
2002- ConsumerSecret='secret')
2003 self.assertEqual(self.protocol.home(), 12)
2004
2005 with open(self._root.format('facebook_ids'), 'r') as fd:
2006
2007=== modified file 'friends/tests/test_flickr.py'
2008--- friends/tests/test_flickr.py 2013-03-25 23:31:22 +0000
2009+++ friends/tests/test_flickr.py 2013-04-16 17:53:25 +0000
2010@@ -86,6 +86,8 @@
2011 # But also no photos.
2012 self.assertEqual(TestModel.get_n_rows(), 0)
2013
2014+ @mock.patch('friends.utils.authentication.manager')
2015+ @mock.patch('friends.utils.authentication.Accounts')
2016 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
2017 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
2018 @mock.patch('friends.utils.http.Soup.Message',
2019@@ -95,14 +97,18 @@
2020 # AccessToken, but this fails.
2021 self.assertRaises(AuthorizationError, self.protocol.receive)
2022
2023+ @mock.patch('friends.utils.authentication.manager')
2024+ @mock.patch('friends.utils.authentication.Accounts')
2025 @mock.patch('friends.utils.http.Soup.Message',
2026 FakeSoupMessage('friends.tests.data', 'flickr-nophotos.dat'))
2027+ @mock.patch('friends.utils.authentication.Authentication.__init__',
2028+ return_value=None)
2029 @mock.patch('friends.utils.authentication.Authentication.login',
2030 return_value=dict(username='Bob Dobbs',
2031 user_nsid='bob',
2032 AccessToken='123',
2033 TokenSecret='abc'))
2034- def test_login_successful_authentication(self, mock):
2035+ def test_login_successful_authentication(self, *mocks):
2036 # Logging in required communication with the account service to get an
2037 # AccessToken, but this fails.
2038 self.protocol.receive()
2039@@ -131,7 +137,7 @@
2040 extras='date_upload,owner_name,icon_server,geo',
2041 format='json',
2042 nojsoncallback='1',
2043- api_key='fake',
2044+ api_key='consume',
2045 method='flickr.photos.getContactsPhotos',
2046 ),
2047 headers={})
2048
2049=== modified file 'friends/tests/test_foursquare.py'
2050--- friends/tests/test_foursquare.py 2013-03-14 19:14:03 +0000
2051+++ friends/tests/test_foursquare.py 2013-04-16 17:53:25 +0000
2052@@ -50,6 +50,8 @@
2053 # The set of public features.
2054 self.assertEqual(FourSquare.get_features(), ['receive'])
2055
2056+ @mock.patch('friends.utils.authentication.manager')
2057+ @mock.patch('friends.utils.authentication.Accounts')
2058 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
2059 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
2060 @mock.patch('friends.utils.http.Downloader.get_json',
2061@@ -59,8 +61,12 @@
2062 self.assertIsNone(self.account.user_name)
2063 self.assertIsNone(self.account.user_id)
2064
2065+ @mock.patch('friends.utils.authentication.manager')
2066+ @mock.patch('friends.utils.authentication.Accounts')
2067 @mock.patch('friends.utils.authentication.Authentication.login',
2068 return_value=dict(AccessToken='tokeny goodness'))
2069+ @mock.patch('friends.utils.authentication.Authentication.__init__',
2070+ return_value=None)
2071 @mock.patch('friends.protocols.foursquare.Downloader.get_json',
2072 return_value=dict(
2073 response=dict(
2074
2075=== modified file 'friends/tests/test_identica.py'
2076--- friends/tests/test_identica.py 2013-04-12 22:06:57 +0000
2077+++ friends/tests/test_identica.py 2013-04-16 17:53:25 +0000
2078@@ -53,6 +53,8 @@
2079 self.log_mock.stop()
2080 shutil.rmtree(self._temp_cache)
2081
2082+ @mock.patch('friends.utils.authentication.manager')
2083+ @mock.patch('friends.utils.authentication.Accounts')
2084 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
2085 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
2086 @mock.patch('friends.utils.http.Downloader.get_json',
2087@@ -62,6 +64,10 @@
2088 self.assertIsNone(self.account.user_name)
2089 self.assertIsNone(self.account.user_id)
2090
2091+ @mock.patch('friends.utils.authentication.manager')
2092+ @mock.patch('friends.utils.authentication.Accounts')
2093+ @mock.patch('friends.utils.authentication.Authentication.__init__',
2094+ return_value=None)
2095 @mock.patch('friends.utils.authentication.Authentication.login',
2096 return_value=dict(AccessToken='some clever fake data',
2097 TokenSecret='sssssshhh!'))
2098
2099=== added file 'friends/tests/test_instagram.py'
2100--- friends/tests/test_instagram.py 1970-01-01 00:00:00 +0000
2101+++ friends/tests/test_instagram.py 2013-04-16 17:53:25 +0000
2102@@ -0,0 +1,215 @@
2103+# friends-dispatcher -- send & receive messages from any social network
2104+# Copyright (C) 2012 Canonical Ltd
2105+#
2106+# This program is free software: you can redistribute it and/or modify
2107+# it under the terms of the GNU General Public License as published by
2108+# the Free Software Foundation, version 3 of the License.
2109+#
2110+# This program is distributed in the hope that it will be useful,
2111+# but WITHOUT ANY WARRANTY; without even the implied warranty of
2112+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2113+# GNU General Public License for more details.
2114+#
2115+# You should have received a copy of the GNU General Public License
2116+# along with this program. If not, see <http://www.gnu.org/licenses/>.
2117+
2118+"""Test the Instagram plugin."""
2119+
2120+
2121+__all__ = [
2122+ 'TestInstagram',
2123+ ]
2124+
2125+
2126+import os
2127+import tempfile
2128+import unittest
2129+import shutil
2130+
2131+from gi.repository import GLib
2132+from pkg_resources import resource_filename
2133+
2134+from friends.protocols.instagram import Instagram
2135+from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
2136+from friends.tests.mocks import TestModel, mock
2137+from friends.tests.mocks import EDSBookClientMock, EDSSource, EDSRegistry
2138+from friends.errors import ContactsError, FriendsError, AuthorizationError
2139+from friends.utils.cache import JsonCache
2140+
2141+
2142+@mock.patch('friends.utils.http._soup', mock.Mock())
2143+@mock.patch('friends.utils.base.notify', mock.Mock())
2144+class TestInstagram(unittest.TestCase):
2145+ """Test the Instagram API."""
2146+
2147+ def setUp(self):
2148+ self._temp_cache = tempfile.mkdtemp()
2149+ self._root = JsonCache._root = os.path.join(
2150+ self._temp_cache, '{}.json')
2151+ self.account = FakeAccount()
2152+ self.protocol = Instagram(self.account)
2153+ self.protocol.source_registry = EDSRegistry()
2154+
2155+ def tearDown(self):
2156+ TestModel.clear()
2157+ shutil.rmtree(self._temp_cache)
2158+
2159+ def test_features(self):
2160+ # The set of public features.
2161+ self.assertEqual(Instagram.get_features(),
2162+ ['home', 'like', 'receive', 'send_thread', 'unlike'])
2163+
2164+ @mock.patch('friends.utils.authentication.manager')
2165+ @mock.patch('friends.utils.authentication.Accounts')
2166+ @mock.patch('friends.utils.authentication.Authentication.__init__',
2167+ return_value=None)
2168+ @mock.patch('friends.utils.authentication.Authentication.login',
2169+ return_value=dict(AccessToken='abc'))
2170+ @mock.patch('friends.utils.http.Soup.Message',
2171+ FakeSoupMessage('friends.tests.data', 'instagram-login.dat'))
2172+ def test_successful_login(self, *mock):
2173+ # Test that a successful response from instagram.com returning
2174+ # the user's data, sets up the account dict correctly.
2175+ self.protocol._login()
2176+ self.assertEqual(self.account.access_token, 'abc')
2177+ self.assertEqual(self.account.user_name, 'bpersons')
2178+ self.assertEqual(self.account.user_id, '801')
2179+
2180+ @mock.patch('friends.utils.authentication.manager')
2181+ @mock.patch('friends.utils.authentication.Accounts')
2182+ @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
2183+ @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
2184+ def test_login_unsuccessful_authentication(self, *mock):
2185+ # The user is not already logged in, but the act of logging in fails.
2186+ self.assertRaises(AuthorizationError, self.protocol._login)
2187+ self.assertIsNone(self.account.access_token)
2188+ self.assertIsNone(self.account.user_name)
2189+
2190+ @mock.patch('friends.utils.http.Soup.Message',
2191+ FakeSoupMessage('friends.tests.data', 'instagram-full.dat'))
2192+ @mock.patch('friends.utils.base.Model', TestModel)
2193+ @mock.patch('friends.protocols.instagram.Instagram._login',
2194+ return_value=True)
2195+ def test_receive(self, *mocks):
2196+ # Receive the feed for a user.
2197+ self.maxDiff = None
2198+ self.account.access_token = 'abc'
2199+ self.assertEqual(self.protocol.receive(), 14)
2200+ self.assertEqual(TestModel.get_n_rows(), 14)
2201+ self.assertEqual(list(TestModel.get_row(0)), [
2202+ 'instagram',
2203+ 88,
2204+ '431474591469914097_223207800',
2205+ 'messages',
2206+ 'Josh',
2207+ '223207800',
2208+ 'joshwolp',
2209+ False,
2210+ '2013-04-11T04:50:01Z',
2211+ 'joshwolp shared a picture on Instagram.',
2212+ GLib.get_user_cache_dir() +
2213+ '/friends/avatars/ca55b643e7b440762c7c6292399eed6542a84b90',
2214+ 'http://instagram.com/joshwolp',
2215+ 8,
2216+ False,
2217+ 'http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_5.jpg',
2218+ '',
2219+ 'http://instagram.com/p/X859raK8fx/',
2220+ '',
2221+ '',
2222+ '',
2223+ '',
2224+ 0.0,
2225+ 0.0,
2226+ ])
2227+ self.assertEqual(list(TestModel.get_row(3)), [
2228+ 'instagram',
2229+ 88,
2230+ '431462132263145102',
2231+ 'reply_to/431438012683111856_5891266',
2232+ 'Syd',
2233+ '5917696',
2234+ 'squidneylol',
2235+ False,
2236+ '2013-04-11T04:25:15Z',
2237+ 'I remember pushing that little guy of the swings a few times....',
2238+ GLib.get_user_cache_dir() +
2239+ '/friends/avatars/e61c8d91e37fec3e1dec9325fa4edc52ebeb96bb',
2240+ '',
2241+ 0,
2242+ False,
2243+ '',
2244+ '',
2245+ '',
2246+ '',
2247+ '',
2248+ '',
2249+ '',
2250+ 0.0,
2251+ 0.0,
2252+ ])
2253+
2254+ @mock.patch('friends.protocols.instagram.Downloader')
2255+ def test_send_thread(self, dload):
2256+ dload().get_json.return_value = dict(id='comment_id')
2257+ token = self.protocol._get_access_token = mock.Mock(
2258+ return_value='abc')
2259+ publish = self.protocol._publish_entry = mock.Mock(
2260+ return_value='http://instagram.com/p/post_id')
2261+
2262+ self.assertEqual(
2263+ self.protocol.send_thread('post_id', 'Some witty response!'),
2264+ 'http://instagram.com/p/post_id')
2265+ token.assert_called_once_with()
2266+ publish.assert_called_with(entry={'id': 'comment_id'},
2267+ stream='reply_to/post_id')
2268+ self.assertEqual(
2269+ dload.mock_calls,
2270+ [mock.call(),
2271+ mock.call(
2272+ 'https://api.instagram.com/v1/media/post_id/comments?access_token=abc',
2273+ method='POST',
2274+ params=dict(
2275+ access_token='abc',
2276+ text='Some witty response!')),
2277+ mock.call().get_json(),
2278+ mock.call('https://api.instagram.com/v1/media/post_id/comments?access_token=abc',
2279+ params=dict(access_token='abc')),
2280+ mock.call().get_json(),
2281+ ])
2282+
2283+ @mock.patch('friends.protocols.instagram.Downloader')
2284+ def test_like(self, dload):
2285+ dload().get_json.return_value = True
2286+ token = self.protocol._get_access_token = mock.Mock(
2287+ return_value='insta')
2288+ inc_cell = self.protocol._inc_cell = mock.Mock()
2289+ set_cell = self.protocol._set_cell = mock.Mock()
2290+
2291+ self.assertEqual(self.protocol.like('post_id'), 'post_id')
2292+
2293+ inc_cell.assert_called_once_with('post_id', 'likes')
2294+ set_cell.assert_called_once_with('post_id', 'liked', True)
2295+ token.assert_called_once_with()
2296+ dload.assert_called_with(
2297+ 'https://api.instagram.com/v1/media/post_id/likes?access_token=insta',
2298+ method='POST',
2299+ params=dict(access_token='insta'))
2300+
2301+ @mock.patch('friends.protocols.instagram.Downloader')
2302+ def test_unlike(self, dload):
2303+ dload.get_json.return_value = True
2304+ token = self.protocol._get_access_token = mock.Mock(
2305+ return_value='insta')
2306+ dec_cell = self.protocol._dec_cell = mock.Mock()
2307+ set_cell = self.protocol._set_cell = mock.Mock()
2308+
2309+ self.assertEqual(self.protocol.unlike('post_id'), 'post_id')
2310+
2311+ dec_cell.assert_called_once_with('post_id', 'likes')
2312+ set_cell.assert_called_once_with('post_id', 'liked', False)
2313+ token.assert_called_once_with()
2314+ dload.assert_called_once_with(
2315+ 'https://api.instagram.com/v1/media/post_id/likes?access_token=insta',
2316+ method='DELETE',
2317+ params=dict(access_token='insta'))
2318
2319=== modified file 'friends/tests/test_logging.py'
2320--- friends/tests/test_logging.py 2013-02-05 01:11:35 +0000
2321+++ friends/tests/test_logging.py 2013-04-16 17:53:25 +0000
2322@@ -27,96 +27,47 @@
2323 import unittest
2324
2325 from friends.utils.logging import initialize
2326+from friends.tests.mocks import mock
2327
2328
2329 class TestLogging(unittest.TestCase):
2330 """Test the logging utilities."""
2331
2332- def setUp(self):
2333- # Preserve the original logger, but restore the global logging system
2334- # to pre-initialized state.
2335- self._loggers = logging.Logger.manager.loggerDict.copy()
2336- logging.Logger.manager.loggerDict.clear()
2337-
2338- def tearDown(self):
2339- # Restore the original loggers.
2340- logging.Logger.manager.loggerDict.update(self._loggers)
2341-
2342- def test_logger_has_filehandler(self):
2343- initialize()
2344- # The top level logger should be available.
2345- log = logging.getLogger()
2346- # Try to find the file opened by the default file handler.
2347- filenames = []
2348- for handler in log.handlers:
2349- if hasattr(handler, 'baseFilename'):
2350- filenames.append(handler.baseFilename)
2351- self.assertGreater(len(filenames), 0)
2352-
2353- def test_logger_message(self):
2354- # Write an error message to the log and test that it shows up.
2355- tempdir = tempfile.mkdtemp()
2356- self.addCleanup(shutil.rmtree, tempdir)
2357- logfile = os.path.join(tempdir, 'friends-test.log')
2358- initialize(filename=logfile)
2359- # Get another handle on the log file.
2360- log = logging.getLogger()
2361- self.assertEqual(os.stat(logfile).st_size, 0)
2362- # Log messages at INFO or higher should be written.
2363- log.info('friends at your service')
2364- self.assertGreater(os.stat(logfile).st_size, 0)
2365- # Read the contents, which should be just one line of output.
2366- with open(logfile, encoding='utf-8') as fp:
2367- contents = fp.read()
2368- lines = contents.splitlines()
2369- self.assertEqual(len(lines), 1)
2370- # The log message will have a variable timestamp, so ignore
2371- # that, but check everything else.
2372- self.assertRegex(
2373- lines[0],
2374- r'INFO\s+MainThread.*root\s+friends at your service')
2375-
2376- def test_console_logger(self):
2377- # The logger can support an optional console logger.
2378- tempdir = tempfile.mkdtemp()
2379- self.addCleanup(shutil.rmtree, tempdir)
2380- logfile = os.path.join(tempdir, 'friends-test.log')
2381- initialize(console=True, filename=logfile)
2382- log = logging.getLogger()
2383- # Can we do better than testing that there are now two handlers for
2384- # the logger?
2385- self.assertEqual(3, sum(1 for handler in log.handlers))
2386-
2387- def test_default_logger_level(self):
2388- # By default, the logger level is INFO.
2389- tempdir = tempfile.mkdtemp()
2390- self.addCleanup(shutil.rmtree, tempdir)
2391- logfile = os.path.join(tempdir, 'friends-test.log')
2392- initialize(filename=logfile)
2393- # Get another handle on the log file.
2394- log = logging.getLogger()
2395- # By default, debug messages won't get written since they are less
2396- # severe than INFO.
2397- log.debug('friends is ready')
2398- self.assertEqual(os.stat(logfile).st_size, 0)
2399-
2400- def test_debug_logger_level(self):
2401- # Set the logger up for debugging.
2402- tempdir = tempfile.mkdtemp()
2403- self.addCleanup(shutil.rmtree, tempdir)
2404- logfile = os.path.join(tempdir, 'friends-test.log')
2405- initialize(filename=logfile, debug=True)
2406- # Get another handle on the log file.
2407- log = logging.getLogger()
2408- log.debug('friends is ready')
2409- self.assertGreater(os.stat(logfile).st_size, 0)
2410- # Read the contents, which should be just one line of output.
2411- with open(logfile, encoding='utf-8') as fp:
2412- contents = fp.read()
2413- lines = contents.splitlines()
2414- self.assertEqual(len(lines), 1)
2415- # The log message will have a variable timestamp at the front, so
2416- # ignore that, but check everything else.
2417- self.assertRegex(
2418- lines[0],
2419- r'DEBUG\s+MainThread.*root\s+friends is ready')
2420+ @mock.patch('friends.utils.logging.logging')
2421+ @mock.patch('friends.utils.logging.os')
2422+ def test_initialize(self, os_mock, log_mock):
2423+ os_mock.path.dirname.return_value = '/dev'
2424+ initialize(filename='/dev/null')
2425+ os_mock.makedirs.assert_called_once_with('/dev')
2426+ os_mock.path.dirname.assert_called_once_with('/dev/null')
2427+
2428+ rot = log_mock.handlers.RotatingFileHandler
2429+ rot.assert_called_once_with(
2430+ '/dev/null', maxBytes=20971520, backupCount=5)
2431+ log_mock.Formatter.assert_called_with(
2432+ '{levelname:5} {threadName:10} {asctime} {name:18} {message}',
2433+ style='{')
2434+ rot().setFormatter.assert_called_once_with(log_mock.Formatter())
2435+
2436+ log_mock.getLogger.assert_called_once_with()
2437+ log_mock.getLogger().setLevel.assert_called_once_with(log_mock.INFO)
2438+ log_mock.getLogger().addHandler.assert_called_once_with(rot())
2439+
2440+ @mock.patch('friends.utils.logging.logging')
2441+ @mock.patch('friends.utils.logging.os')
2442+ def test_initialize_console(self, os_mock, log_mock):
2443+ os_mock.path.dirname.return_value = '/dev'
2444+ initialize(True, True, filename='/dev/null')
2445+ os_mock.makedirs.assert_called_once_with('/dev')
2446+ os_mock.path.dirname.assert_called_once_with('/dev/null')
2447+
2448+ stream = log_mock.StreamHandler
2449+ stream.assert_called_once_with()
2450+ log_mock.Formatter.assert_called_with(
2451+ '{levelname:5} {threadName:10} {name:18} {message}',
2452+ style='{')
2453+ stream().setFormatter.assert_called_once_with(log_mock.Formatter())
2454+
2455+ log_mock.getLogger.assert_called_once_with()
2456+ log_mock.getLogger().setLevel.assert_called_once_with(log_mock.DEBUG)
2457+ log_mock.getLogger().addHandler.assert_called_with(stream())
2458
2459=== modified file 'friends/tests/test_shortener.py'
2460--- friends/tests/test_shortener.py 2013-03-29 01:50:16 +0000
2461+++ friends/tests/test_shortener.py 2013-04-16 17:53:25 +0000
2462@@ -22,7 +22,7 @@
2463
2464 import unittest
2465
2466-from friends.shorteners import isgd, ougd, linkeecom, lookup, tinyurlcom
2467+from friends.utils.shorteners import Short
2468 from friends.tests.mocks import FakeSoupMessage, mock
2469
2470
2471@@ -33,83 +33,115 @@
2472 @mock.patch('friends.utils.http.Soup.Message',
2473 FakeSoupMessage('friends.tests.data', 'short.dat'))
2474 def test_isgd(self):
2475- # Test the shortener.
2476 self.assertEqual(
2477- isgd.URLShortener().shorten('http://www.python.org'),
2478+ Short('is.gd').make('http://www.python.org'),
2479 'http://sho.rt/')
2480
2481- def test_isgd_protocol(self):
2482- self.assertEqual(isgd.URLShortener.name, 'is.gd')
2483- self.assertEqual(isgd.URLShortener.fqdn, 'http://is.gd')
2484-
2485 @mock.patch('friends.utils.http.Soup.Message',
2486 FakeSoupMessage('friends.tests.data', 'short.dat'))
2487 def test_ougd(self):
2488- # Test the shortener.
2489 self.assertEqual(
2490- ougd.URLShortener().shorten('http://www.python.org'),
2491+ Short('ou.gd').make('http://www.python.org'),
2492 'http://sho.rt/')
2493
2494- def test_ougd_protocol(self):
2495- self.assertEqual(ougd.URLShortener.name, 'ou.gd')
2496- self.assertEqual(ougd.URLShortener.fqdn, 'http://ou.gd')
2497-
2498 @mock.patch('friends.utils.http.Soup.Message',
2499 FakeSoupMessage('friends.tests.data', 'short.dat'))
2500 def test_linkeecom(self):
2501- # Test the shortener.
2502 self.assertEqual(
2503- linkeecom.URLShortener().shorten('http://www.python.org'),
2504+ Short('linkee.com').make('http://www.python.org'),
2505 'http://sho.rt/')
2506
2507- def test_linkeecom_protocol(self):
2508- self.assertEqual(linkeecom.URLShortener.name, 'linkee.com')
2509- self.assertEqual(linkeecom.URLShortener.fqdn, 'http://linkee.com')
2510-
2511 @mock.patch('friends.utils.http.Soup.Message',
2512 FakeSoupMessage('friends.tests.data', 'short.dat'))
2513 def test_tinyurlcom(self):
2514- # Test the shortener.
2515 self.assertEqual(
2516- tinyurlcom.URLShortener().shorten('http://www.python.org'),
2517+ Short('tinyurl.com').make('http://www.python.org'),
2518 'http://sho.rt/')
2519
2520- def test_tinyurlcom_protocol(self):
2521- self.assertEqual(tinyurlcom.URLShortener.name, 'tinyurl.com')
2522- self.assertEqual(tinyurlcom.URLShortener.fqdn, 'http://tinyurl.com')
2523-
2524 @mock.patch('friends.utils.http.Soup.Message',
2525- FakeSoupMessage('friends.tests.data', 'short.dat'))
2526- def test_enabled_lookup(self):
2527- # Look up an enabled shortener.
2528- shortener = lookup.lookup('tinyurl.com')
2529+ FakeSoupMessage('friends.tests.data', 'durlme.dat'))
2530+ def test_durlme(self):
2531 self.assertEqual(
2532- shortener.shorten('http://www.python.org'),
2533- 'http://sho.rt/')
2534+ Short('durl.me').make('http://www.python.org'),
2535+ 'http://durl.me/5o')
2536
2537 def test_missing_or_disabled_lookup(self):
2538 # Looking up a non-existent or disabled shortener gives you one that
2539 # returns the original url back unchanged.
2540- shortener = lookup.lookup('dummy')
2541- self.assertEqual(
2542- shortener.shorten('http://www.python.org'),
2543+ self.assertEqual(
2544+ Short('nonexistant').make('http://www.python.org'),
2545+ 'http://www.python.org')
2546+ self.assertEqual(
2547+ Short().make('http://www.python.org'),
2548 'http://www.python.org')
2549
2550 def test_is_shortened(self):
2551- # Test a URL that has been shortened.
2552- self.assertTrue(lookup.is_shortened('http://tinyurl.com/foo'))
2553- self.assertTrue(lookup.is_shortened('http://is.gd/foo'))
2554- self.assertTrue(lookup.is_shortened('http://linkee.com/foo'))
2555- self.assertTrue(lookup.is_shortened('http://ou.gd/foo'))
2556+ # Test URLs that have been shortened.
2557+ self.assertTrue(Short.already('http://tinyurl.com/foo'))
2558+ self.assertTrue(Short.already('http://is.gd/foo'))
2559+ self.assertTrue(Short.already('http://linkee.com/foo'))
2560+ self.assertTrue(Short.already('http://ou.gd/foo'))
2561+ self.assertTrue(Short.already('http://durl.me/foo'))
2562
2563 def test_is_not_shortened(self):
2564 # Test a URL that has not been shortened.
2565- self.assertFalse(lookup.is_shortened('http://www.python.org/bar'))
2566-
2567- @mock.patch('friends.shorteners.base.Downloader')
2568- def test_urls_quoted_properly(self, dl_mock):
2569- lookup.lookup('tinyurl.com').shorten(
2570+ self.assertFalse(Short.already('http://www.python.org/bar'))
2571+
2572+ @mock.patch('friends.utils.shorteners.Downloader')
2573+ def test_isgd_quoted_properly(self, dl_mock):
2574+ Short('is.gd').make('http://example.com/~user/stuff/+things')
2575+ dl_mock.assert_called_once_with(
2576+ 'http://is.gd/api.php?longurl=http%3A%2F%2Fexample.com'
2577+ '%2F%7Euser%2Fstuff%2F%2Bthings')
2578+
2579+ @mock.patch('friends.utils.shorteners.Downloader')
2580+ def test_ougd_quoted_properly(self, dl_mock):
2581+ Short('ou.gd').make('http://example.com/~user/stuff/+things')
2582+ dl_mock.assert_called_once_with(
2583+ 'http://ou.gd/api.php?format=simple&action=shorturl&url='
2584+ 'http%3A%2F%2Fexample.com%2F%7Euser%2Fstuff%2F%2Bthings')
2585+
2586+ @mock.patch('friends.utils.shorteners.Downloader')
2587+ def test_linkeecom_quoted_properly(self, dl_mock):
2588+ Short('linkee.com').make(
2589+ 'http://example.com/~user/stuff/+things')
2590+ dl_mock.assert_called_once_with(
2591+ 'http://api.linkee.com/1.0/shorten?format=text&input='
2592+ 'http%3A%2F%2Fexample.com%2F%7Euser%2Fstuff%2F%2Bthings')
2593+
2594+ @mock.patch('friends.utils.shorteners.Downloader')
2595+ def test_tinyurl_quoted_properly(self, dl_mock):
2596+ Short('tinyurl.com').make(
2597 'http://example.com/~user/stuff/+things')
2598 dl_mock.assert_called_once_with(
2599 'http://tinyurl.com/api-create.php?url=http%3A%2F%2Fexample.com'
2600 '%2F%7Euser%2Fstuff%2F%2Bthings')
2601+
2602+ @mock.patch('friends.utils.shorteners.Downloader')
2603+ def test_durlme_quoted_properly(self, dl_mock):
2604+ dl_mock().get_string().strip.return_value = ''
2605+ dl_mock.reset_mock()
2606+ Short('durl.me').make(
2607+ 'http://example.com/~user/stuff/+things')
2608+ dl_mock.assert_called_once_with(
2609+ 'http://durl.me/api/Create.do?type=json&longurl='
2610+ 'http%3A%2F%2Fexample.com%2F%7Euser%2Fstuff%2F%2Bthings')
2611+
2612+ @mock.patch('friends.utils.shorteners.Downloader')
2613+ def test_dont_over_shorten(self, dl_mock):
2614+ Short('tinyurl.com').make('http://tinyurl.com/page_id')
2615+ Short('linkee.com').make('http://ou.gd/page_id')
2616+ Short('is.gd').make('http://is.gd/page_id')
2617+ Short('ou.gd').make('http://linkee.com/page_id')
2618+ self.assertEqual(dl_mock.call_count, 0)
2619+
2620+ def test_find_all_in_string(self):
2621+ shorter = Short()
2622+ shorter.make = lambda url: 'zombo.com'
2623+ self.assertEqual(
2624+ 'Welcome to zombo.com, anything is possible. '
2625+ 'You can do anything at zombo.com!',
2626+ shorter.sub(
2627+ 'Welcome to http://example.com/really/really/long/url, '
2628+ 'anything is possible. You can do anything at '
2629+ 'http://example.com!'))
2630
2631=== modified file 'friends/tests/test_twitter.py'
2632--- friends/tests/test_twitter.py 2013-04-12 22:06:57 +0000
2633+++ friends/tests/test_twitter.py 2013-04-16 17:53:25 +0000
2634@@ -57,15 +57,21 @@
2635 self.log_mock.stop()
2636 shutil.rmtree(self._temp_cache)
2637
2638+ @mock.patch('friends.utils.authentication.manager')
2639+ @mock.patch('friends.utils.authentication.Accounts')
2640 @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
2641 @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
2642 @mock.patch('friends.protocols.twitter.Downloader.get_json',
2643 return_value=None)
2644- def test_unsuccessful_authentication(self, dload, login):
2645+ def test_unsuccessful_authentication(self, dload, login, *mocks):
2646 self.assertRaises(AuthorizationError, self.protocol._login)
2647 self.assertIsNone(self.account.user_name)
2648 self.assertIsNone(self.account.user_id)
2649
2650+ @mock.patch('friends.utils.authentication.manager')
2651+ @mock.patch('friends.utils.authentication.Accounts')
2652+ @mock.patch('friends.utils.authentication.Authentication.__init__',
2653+ return_value=None)
2654 @mock.patch('friends.utils.authentication.Authentication.login',
2655 return_value=dict(AccessToken='some clever fake data',
2656 TokenSecret='sssssshhh!',
2657@@ -91,11 +97,12 @@
2658 def test_signatures(self, dload):
2659 self.account.secret_token = 'alpha'
2660 self.account.access_token = 'omega'
2661- self.account.auth.id = 6
2662- self.account.auth.method = 'oauth2'
2663- self.account.auth.mechanism = 'HMAC-SHA1'
2664- self.account.auth.parameters = dict(ConsumerKey='consume',
2665- ConsumerSecret='obey')
2666+ self.account.consumer_secret = 'obey'
2667+ self.account.consumer_key = 'consume'
2668+ self.account.auth.get_credentials_id = lambda *ignore: 6
2669+ self.account.auth.get_method = lambda *ignore: 'oauth2'
2670+ self.account.auth.get_mechanism = lambda *ignore: 'HMAC-SHA1'
2671+
2672 result = '''\
2673 OAuth oauth_nonce="once%20upon%20a%20nonce", \
2674 oauth_timestamp="1348690628", \
2675@@ -127,9 +134,6 @@
2676 def test_home(self, *mocks):
2677 self.account.access_token = 'access'
2678 self.account.secret_token = 'secret'
2679- self.account.auth.parameters = dict(
2680- ConsumerKey='key',
2681- ConsumerSecret='secret')
2682 self.assertEqual(0, TestModel.get_n_rows())
2683 self.assertEqual(self.protocol.home(), 3)
2684 self.assertEqual(3, TestModel.get_n_rows())
2685@@ -176,9 +180,6 @@
2686 def test_home_since_id(self, *mocks):
2687 self.account.access_token = 'access'
2688 self.account.secret_token = 'secret'
2689- self.account.auth.parameters = dict(
2690- ConsumerKey='key',
2691- ConsumerSecret='secret')
2692 self.assertEqual(self.protocol.home(), 3)
2693
2694 with open(self._root.format('twitter_ids'), 'r') as fd:
2695@@ -201,9 +202,6 @@
2696 self.account.access_token = 'access'
2697 self.account.secret_token = 'secret'
2698 self.account.user_name = 'oauth_dancer'
2699- self.account.auth.parameters = dict(
2700- ConsumerKey='key',
2701- ConsumerSecret='secret')
2702 self.assertEqual(0, TestModel.get_n_rows())
2703 self.assertEqual(
2704 self.protocol.send('some message'),
2705@@ -404,9 +402,6 @@
2706 def test_send_thread_prepend_nick(self, *mocks):
2707 self.account.access_token = 'access'
2708 self.account.secret_token = 'secret'
2709- self.account.auth.parameters = dict(
2710- ConsumerKey='key',
2711- ConsumerSecret='secret')
2712 self.assertEqual(0, TestModel.get_n_rows())
2713 self.assertEqual(self.protocol.home(), 3)
2714 self.assertEqual(3, TestModel.get_n_rows())
2715@@ -751,9 +746,6 @@
2716 def test_protocol_rate_limiting(self, time, sleep, login):
2717 self.account.access_token = 'access'
2718 self.account.secret_token = 'secret'
2719- self.account.auth.parameters = dict(
2720- ConsumerKey='key',
2721- ConsumerSecret='secret')
2722 # Test rate limiting via the Twitter plugin API.
2723 #
2724 # The first call doesn't get rate limited.
2725
2726=== modified file 'friends/utils/account.py'
2727--- friends/utils/account.py 2013-03-13 02:03:12 +0000
2728+++ friends/utils/account.py 2013-04-16 17:53:25 +0000
2729@@ -17,7 +17,7 @@
2730
2731 __all__ = [
2732 'Account',
2733- 'AccountManager',
2734+ 'find_accounts',
2735 ]
2736
2737
2738@@ -28,65 +28,30 @@
2739
2740 from friends.errors import UnsupportedProtocolError
2741 from friends.utils.manager import protocol_manager
2742+from friends.utils.authentication import manager
2743
2744
2745 log = logging.getLogger(__name__)
2746
2747
2748-class AccountManager:
2749- """Manage the accounts that we know about."""
2750-
2751- def __init__(self):
2752- self._accounts = {}
2753- # Ask libaccounts for a manager of the microblogging services.
2754- # Connect callbacks to the manager so that we can react when accounts
2755- # are added or deleted.
2756- manager = Accounts.Manager.new_for_service_type('microblogging')
2757- manager.connect('enabled-event', self._on_enabled_event)
2758- # Add all the currently known accounts.
2759- for account_service in manager.get_enabled_account_services():
2760- self._add_new_account(account_service)
2761- log.info('Accounts found: {}'.format(len(self._accounts)))
2762-
2763- def _get_service(self, manager, account_id):
2764- """Instantiate an AccountService and identify it."""
2765- account = manager.get_account(account_id)
2766- for service in account.list_services():
2767- return Accounts.AccountService.new(account, service)
2768-
2769- def _on_enabled_event(self, manager, account_id):
2770- """React to new microblogging accounts being enabled or disabled."""
2771- account_service = self._get_service(manager, account_id)
2772- if account_service is not None and account_service.get_enabled():
2773- log.debug('Adding account {}'.format(account_id))
2774- account = self._add_new_account(account_service)
2775- if account is not None:
2776- account.protocol('receive')
2777-
2778- def _add_new_account(self, account_service):
2779+def _find_accounts_uoa():
2780+ """Consult Ubuntu Online Accounts for the accounts we have."""
2781+ accounts = {}
2782+ for service in manager.get_enabled_account_services():
2783 try:
2784- new_account = Account(account_service)
2785+ account = Account(service)
2786 except UnsupportedProtocolError as error:
2787 log.info(error)
2788 else:
2789- self._accounts[new_account.id] = new_account
2790- return new_account
2791-
2792- def get_all(self):
2793- return self._accounts.values()
2794-
2795- def get(self, account_id, default=None):
2796- return self._accounts.get(int(account_id), default)
2797-
2798-
2799-class AuthData:
2800- """This class serves as a sub-namespace for Account instances."""
2801-
2802- def __init__(self, auth_data):
2803- self.id = auth_data.get_credentials_id()
2804- self.method = auth_data.get_method()
2805- self.mechanism = auth_data.get_mechanism()
2806- self.parameters = auth_data.get_parameters()
2807+ accounts[account.id] = account
2808+ log.info('Accounts found: {}'.format(len(accounts)))
2809+ return accounts
2810+
2811+
2812+def find_accounts():
2813+ # TODO: Implement GOA support, then fill out this method with some
2814+ # logic for determining whether to use UOA or GOA.
2815+ return _find_accounts_uoa()
2816
2817
2818 class Account:
2819@@ -99,6 +64,8 @@
2820 )
2821
2822 # Defaults for the known and useful attributes.
2823+ consumer_secret = None
2824+ consumer_key = None
2825 access_token = None
2826 secret_token = None
2827 send_enabled = None
2828@@ -108,15 +75,22 @@
2829 id = None
2830
2831 def __init__(self, account_service):
2832- self.account_service = account_service
2833- self.auth = AuthData(account_service.get_auth_data())
2834+ self.auth = account_service.get_auth_data()
2835+ if self.auth is not None:
2836+ auth_params = self.auth.get_parameters()
2837+ self.consumer_key = auth_params.get('ConsumerKey')
2838+ self.consumer_secret = auth_params.get('ConsumerSecret')
2839+ else:
2840+ raise UnsupportedProtocolError(
2841+ 'This AgAccountService is missing AgAuthData!')
2842+
2843 # The provider in libaccounts should match the name of our protocol.
2844 account = account_service.get_account()
2845 self.id = account.id
2846- self.protocol_name = account.get_provider_name()
2847- protocol_class = protocol_manager.protocols.get(self.protocol_name)
2848+ protocol_name = account.get_provider_name()
2849+ protocol_class = protocol_manager.protocols.get(protocol_name)
2850 if protocol_class is None:
2851- raise UnsupportedProtocolError(self.protocol_name)
2852+ raise UnsupportedProtocolError(protocol_name)
2853 self.protocol = protocol_class(self)
2854 # Connect responders to changes in the account information.
2855 account_service.connect('changed', self._on_account_changed, account)
2856@@ -125,51 +99,9 @@
2857 self.login_lock = Lock()
2858
2859 def _on_account_changed(self, account_service, account):
2860- settings = account.get_settings_iter('friends/')
2861- # This is horrible on several fronts. Ideally, we'd like to just get
2862- # the small set of values that we care about, but the Python bindings
2863- # for gi.repository.Accounts does not make this easy. First, there's
2864- # no direct mapping to .get_value() - you have to use .get_string(),
2865- # .get_int(), and .get_bool(). But even there, it's not clear that
2866- # the values its returning are the right settings values. E.g. in my
2867- # tests, I received *different* values than the ones I expected, or
2868- # the ones returned from the iterator below. It's also way to easy to
2869- # segfault .get_value() -- try this:
2870- #
2871- # account_service.get_bool('friends/send_enabled')
2872- #
2873- # KABOOM!
2874- #
2875- # The other problem here is that libaccounts doesn't provide an
2876- # override for AgAccountSettingIter so that it supports the Python
2877- # iteration protocol. We could use 2-argument built-in iter() with a
2878- # sentinel of (False, None, None), but afaict, the second and third
2879- # items are undocumented when the first is False, so that would just
2880- # be crossing our fingers.
2881- #
2882- # Of all the options, this appears to be the most reliable and safest
2883- # way until the libaccounts API improves.
2884- while True:
2885- success, key, value = settings.next()
2886- if success:
2887- log.debug('{} got {}: {}'.format(self.id, key, value))
2888- # Testing for tuple membership makes this easy to expand
2889- # later, if necessary.
2890- if key in Account._LIBACCOUNTS_PROPERTIES:
2891- setattr(self, key, value)
2892- else:
2893- break
2894-
2895- @property
2896- def enabled(self):
2897- return self.account_service.get_enabled()
2898-
2899- def __eq__(self, other):
2900- if other is None:
2901- return False
2902- return self.account_service == other.account_service
2903-
2904- def __ne__(self, other):
2905- if other is None:
2906- return True
2907- return self.account_service != other.account_service
2908+ settings = account.get_settings_dict('friends/')
2909+ for (key, value) in settings.items():
2910+ if key in Account._LIBACCOUNTS_PROPERTIES:
2911+ log.debug('{} ({}) got {}: {}'.format(
2912+ self.protocol._Name, self.id, key, value))
2913+ setattr(self, key, value)
2914
2915=== modified file 'friends/utils/authentication.py'
2916--- friends/utils/authentication.py 2013-03-13 17:34:26 +0000
2917+++ friends/utils/authentication.py 2013-04-16 17:53:25 +0000
2918@@ -23,7 +23,7 @@
2919 import logging
2920 import time
2921
2922-from gi.repository import GObject, Signon
2923+from gi.repository import GObject, Accounts, Signon
2924
2925 from friends.errors import AuthorizationError
2926
2927@@ -37,17 +37,35 @@
2928 LOGIN_TIMEOUT = 30 # Currently this is measured in half-seconds.
2929
2930
2931+# Yes, this is not the most logical place to instantiate this, but I
2932+# couldn't do it in account.py due to cyclical import dependencies.
2933+manager = Accounts.Manager.new_for_service_type('microblogging')
2934+
2935+
2936 class Authentication:
2937- def __init__(self, account):
2938- self.account = account
2939+ def __init__(self, account_id):
2940+ self.account_id = account_id
2941+ account = manager.get_account(account_id)
2942+ for service in account.list_services():
2943+ self.auth = Accounts.AccountService.new(
2944+ account, service).get_auth_data()
2945+ break
2946+ else:
2947+ raise AuthorizationError(
2948+ account_id,
2949+ 'No AgService found, is your UOA plugin written correctly?')
2950 self._reply = None
2951
2952 def login(self):
2953- auth = self.account.auth
2954- self.auth_session = Signon.AuthSession.new(auth.id, auth.method)
2955+ auth = self.auth
2956+ self.auth_session = Signon.AuthSession.new(
2957+ auth.get_credentials_id(),
2958+ auth.get_method())
2959 self.auth_session.process(
2960- auth.parameters, auth.mechanism,
2961- self._login_cb, None)
2962+ auth.get_parameters(),
2963+ auth.get_mechanism(),
2964+ self._login_cb,
2965+ None)
2966 timeout = LOGIN_TIMEOUT
2967 while self._reply is None and timeout > 0:
2968 # We're building a synchronous API on top of an inherently
2969@@ -56,17 +74,17 @@
2970 time.sleep(0.5)
2971 timeout -= 1
2972 if self._reply is None:
2973- raise AuthorizationError(self.account.id, 'Login timed out.')
2974+ raise AuthorizationError(self.account_id, 'Login timed out.')
2975 if 'AccessToken' not in self._reply:
2976 raise AuthorizationError(
2977- self.account.id,
2978+ self.account_id,
2979 'No AccessToken found: {!r}'.format(self._reply))
2980 return self._reply
2981
2982 def _login_cb(self, session, reply, error, user_data):
2983 self._reply = reply
2984 if error:
2985- exception = AuthorizationError(self.account.id, error.message)
2986+ exception = AuthorizationError(self.account_id, error.message)
2987 # Mardy says this error can happen during normal operation.
2988 if error.message.endswith('userActionFinished error: 10'):
2989 log.error(str(exception))
2990
2991=== modified file 'friends/utils/avatar.py'
2992--- friends/utils/avatar.py 2013-03-27 03:41:27 +0000
2993+++ friends/utils/avatar.py 2013-04-16 17:53:25 +0000
2994@@ -21,7 +21,6 @@
2995
2996
2997 import os
2998-import errno
2999 import logging
3000
3001 from datetime import date, timedelta
3002@@ -29,6 +28,7 @@
3003 from hashlib import sha1
3004
3005 from friends.utils.http import Downloader
3006+from friends.errors import ignored
3007
3008
3009 CACHE_DIR = os.path.realpath(os.path.join(
3010@@ -36,13 +36,8 @@
3011 AGE_LIMIT = date.today() - timedelta(weeks=4)
3012
3013
3014-try:
3015+with ignored(FileExistsError):
3016 os.makedirs(CACHE_DIR)
3017-except OSError as error:
3018- # It raises OSError if the dir already existed, which is fine,
3019- # but don't ignore other errors.
3020- if error.errno != errno.EEXIST:
3021- raise
3022
3023
3024 log = logging.getLogger(__name__)
3025@@ -58,15 +53,14 @@
3026 if not url:
3027 return url
3028 local_path = Avatar.get_path(url)
3029- try:
3030- size = os.stat(local_path).st_size
3031- mtime = date.fromtimestamp(os.stat(local_path).st_mtime)
3032- except OSError as error:
3033- if error.errno != errno.ENOENT:
3034- # Some other error occurred, so propagate it up.
3035- raise
3036- # Treat a missing file as zero length.
3037- size = 0
3038+ size = 0
3039+ mtime = date.fromtimestamp(0)
3040+
3041+ with ignored(FileNotFoundError):
3042+ stat = os.stat(local_path)
3043+ size = stat.st_size
3044+ mtime = date.fromtimestamp(stat.st_mtime)
3045+
3046 if size == 0 or mtime < AGE_LIMIT:
3047 log.debug('Getting: {}'.format(url))
3048 image_data = Downloader(url).get_bytes()
3049@@ -97,9 +91,6 @@
3050 # The file's last modification time is earlier than the oldest
3051 # time we'll allow in the cache. However, due to race
3052 # conditions, ignore it if the file has already been removed.
3053- try:
3054+ with ignored(FileNotFoundError):
3055 log.debug('Expiring: {}'.format(path))
3056 os.remove(path)
3057- except OSError as error:
3058- if error.errno != errno.ENOENT:
3059- raise
3060
3061=== modified file 'friends/utils/base.py'
3062--- friends/utils/base.py 2013-04-08 19:33:45 +0000
3063+++ friends/utils/base.py 2013-04-16 17:53:25 +0000
3064@@ -461,7 +461,7 @@
3065 log.debug('{} to {}'.format(
3066 'Re-authenticating' if old_token else 'Logging in', self._Name))
3067
3068- result = Authentication(self._account).login()
3069+ result = Authentication(self._account.id).login()
3070
3071 self._account.access_token = result.get('AccessToken')
3072 self._whoami(result)
3073@@ -470,8 +470,8 @@
3074 def _get_oauth_headers(self, method, url, data=None, headers=None):
3075 """Basic wrapper around oauthlib that we use for Twitter and Flickr."""
3076 # "Client" == "Consumer" in oauthlib parlance.
3077- client_key = self._account.auth.parameters['ConsumerKey']
3078- client_secret = self._account.auth.parameters['ConsumerSecret']
3079+ client_key = self._account.consumer_key
3080+ client_secret = self._account.consumer_secret
3081
3082 # "resource_owner" == secret and token.
3083 resource_owner_key = self._get_access_token()
3084
3085=== modified file 'friends/utils/cache.py'
3086--- friends/utils/cache.py 2013-03-08 02:31:15 +0000
3087+++ friends/utils/cache.py 2013-04-16 17:53:25 +0000
3088@@ -21,11 +21,12 @@
3089
3090 import os
3091 import json
3092-import errno
3093 import logging
3094
3095 from gi.repository import GLib
3096
3097+from friends.errors import ignored
3098+
3099
3100 log = logging.getLogger(__name__)
3101
3102@@ -59,9 +60,7 @@
3103 try:
3104 with open(self._path, 'r') as cache:
3105 self.update(json.loads(cache.read()))
3106- except IOError as error:
3107- if error.errno != errno.ENOENT:
3108- raise
3109+ except FileNotFoundError:
3110 # This writes '{}' to self._filename on first run.
3111 self.write()
3112
3113
3114=== modified file 'friends/utils/logging.py'
3115--- friends/utils/logging.py 2013-02-05 01:11:35 +0000
3116+++ friends/utils/logging.py 2013-04-16 17:53:25 +0000
3117@@ -16,13 +16,14 @@
3118 """Logging utilities."""
3119
3120 import os
3121-import errno
3122 import logging
3123 import logging.handlers
3124 import oauthlib.oauth1
3125
3126 from gi.repository import GLib
3127
3128+from friends.errors import ignored
3129+
3130
3131 # Set a global default of no logging. This is a workaround for a bug
3132 # where we were getting duplicated log records.
3133@@ -53,11 +54,8 @@
3134 # Start by ensuring that the directory containing the log file exists.
3135 if filename is None:
3136 filename = LOG_FILENAME
3137- try:
3138+ with ignored(FileExistsError):
3139 os.makedirs(os.path.dirname(filename))
3140- except OSError as error:
3141- if error.errno != errno.EEXIST:
3142- raise
3143
3144 # Install a rotating log file handler. XXX There should be a
3145 # configuration file rather than hard-coded values.
3146@@ -67,15 +65,15 @@
3147 text_formatter = logging.Formatter(LOG_FORMAT, style='{')
3148 text_handler.setFormatter(text_formatter)
3149
3150- console_handler = logging.StreamHandler()
3151- console_formatter = logging.Formatter(CSL_FORMAT, style='{')
3152- console_handler.setFormatter(console_formatter)
3153-
3154 log = logging.getLogger()
3155+ log.addHandler(text_handler)
3156+
3157 if debug:
3158 log.setLevel(logging.DEBUG)
3159 else:
3160 log.setLevel(logging.INFO)
3161 if console:
3162+ console_handler = logging.StreamHandler()
3163+ console_formatter = logging.Formatter(CSL_FORMAT, style='{')
3164+ console_handler.setFormatter(console_formatter)
3165 log.addHandler(console_handler)
3166- log.addHandler(text_handler)
3167
3168=== modified file 'friends/utils/menus.py'
3169--- friends/utils/menus.py 2013-03-21 19:56:39 +0000
3170+++ friends/utils/menus.py 2013-04-16 17:53:25 +0000
3171@@ -19,12 +19,12 @@
3172 import subprocess
3173
3174
3175+from friends.errors import ignored
3176+
3177 MessagingMenu = None
3178 """ Disable messaging menu integration until we have some sort of handler
3179-try:
3180+with ignored(ImportError):
3181 from gi.repository import MessagingMenu
3182-except ImportError:
3183- pass
3184 """
3185
3186
3187
3188=== modified file 'friends/utils/notify.py'
3189--- friends/utils/notify.py 2013-02-05 01:11:35 +0000
3190+++ friends/utils/notify.py 2013-04-16 17:53:25 +0000
3191@@ -27,6 +27,8 @@
3192
3193 from gi.repository import GObject, GdkPixbuf
3194
3195+from friends.errors import ignored
3196+
3197
3198 # This gets conditionally imported at the end of this file, which
3199 # allows for easier overriding of the following function definition.
3200@@ -41,11 +43,9 @@
3201 notification = Notify.Notification.new(
3202 title, message, 'friends')
3203
3204- try:
3205+ with ignored(GObject.GError):
3206 pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
3207 icon_uri, 48, 48)
3208- except GObject.GError:
3209- pass
3210
3211 if pixbuf is not None:
3212 notification.set_icon_from_pixbuf(pixbuf)
3213@@ -53,12 +53,10 @@
3214 if _notify_can_append:
3215 notification.set_hint_string('x-canonical-append', 'allowed')
3216
3217- try:
3218- notification.show()
3219- except GObject.GError:
3220+ with ignored(GObject.GError):
3221 # Most likely we've spammed more than 50 notificatons,
3222 # not much we can do about that.
3223- pass
3224+ notification.show()
3225
3226 # Optional dependency on Notify library.
3227 try:
3228
3229=== renamed file 'friends/shorteners/lookup.py' => 'friends/utils/shorteners.py'
3230--- friends/shorteners/lookup.py 2013-02-13 02:05:37 +0000
3231+++ friends/utils/shorteners.py 2013-04-16 17:53:25 +0000
3232@@ -17,50 +17,68 @@
3233
3234
3235 __all__ = [
3236- 'PROTOCOLS',
3237- 'is_shortened',
3238- 'lookup',
3239+ 'Short',
3240 ]
3241
3242
3243-from friends.shorteners import isgd, ougd, tinyurlcom, linkeecom
3244-
3245-
3246-PROTOCOLS = {
3247- 'is.gd': isgd,
3248- 'ou.gd': ougd,
3249- 'linkee.com': linkeecom,
3250- 'tinyurl.com': tinyurlcom,
3251+import re
3252+
3253+from urllib.parse import quote
3254+
3255+from friends.utils.base import LINKIFY_REGEX as replace_urls
3256+from friends.utils.http import Downloader
3257+
3258+
3259+# These strings define the shortening services. If you want to add a
3260+# new shortener to this list, the shortening service must take the URL
3261+# as a parameter, and return the plaintext URL as the result. No JSON
3262+# or XML parsing is supported. The strings below must contain exactly
3263+# one instance of '{}' to represent where the long URL goes in the
3264+# service. This is typically at the very end, but doesn't have to be.
3265+URLS = {
3266+ 'is.gd': 'http://is.gd/api.php?longurl={}',
3267+ 'linkee.com': 'http://api.linkee.com/1.0/shorten?format=text&input={}',
3268+ 'ou.gd': 'http://ou.gd/api.php?format=simple&action=shorturl&url={}',
3269+ 'tinyurl.com': 'http://tinyurl.com/api-create.php?url={}',
3270+ 'durl.me': 'http://durl.me/api/Create.do?type=json&longurl={}',
3271 }
3272
3273
3274-class NoShortener:
3275- """The default URL 'shortener' which doesn't shorten at all.
3276-
3277- If the chosen shortener isn't found, or is disabled, then this one is
3278- returned. It supports the standard API but just returns the original URL
3279- unchanged.
3280- """
3281-
3282- class URLShortener:
3283- def shorten(self, url):
3284+class Short:
3285+ """Each instance of this class represents a unique shortening service."""
3286+
3287+ def __init__(self, domain=None):
3288+ """Determine which shortening service this instance will use."""
3289+ self.template = URLS.get(domain)
3290+ self.domain = domain
3291+
3292+ # Disable shortening if no shortener found.
3293+ if None in (domain, self.template):
3294+ self.make = lambda url: url
3295+ return
3296+
3297+ if "json" in self.template:
3298+ self.make = self.json
3299+
3300+ def make(self, url):
3301+ """Shorten the URL by querying the shortening service."""
3302+ if Short.already(url):
3303+ # Don't re-shorten an already-short URL.
3304 return url
3305-
3306-
3307-def lookup(name):
3308- """Look up a URL shortener by name.
3309-
3310- :param name: The name of a shortener.
3311- :type name: string
3312- :return: An object supporting the `shorten(url)` method.
3313- """
3314- return PROTOCOLS.get(name, NoShortener).URLShortener()
3315-
3316-
3317-def is_shortened(url):
3318- """True if the URL has been shortened by a known shortener."""
3319- # What if we tried to URL shorten http://tinyurl.com/something???
3320- for module in PROTOCOLS.values():
3321- if url.startswith(module.URLShortener.fqdn):
3322- return True
3323- return False
3324+ return Downloader(
3325+ self.template.format(quote(url, safe=''))).get_string().strip()
3326+
3327+ def sub(self, message):
3328+ """Find *all* of the URLs in a string and shorten all of them."""
3329+ return replace_urls(lambda match: self.make(match.group(0)), message)
3330+
3331+ def json(self, url):
3332+ """Grab URLs swiftly with regex."""
3333+ # Avoids writing JSON code that is service-specific.
3334+ find = re.compile('https?://{}[^\"]+'.format(self.domain)).findall
3335+ for short in find(Short.make(self, url)):
3336+ return short
3337+ return url
3338+
3339+ # Used for checking if URLs have already been shortened.
3340+ already = re.compile(r'https?://({})/'.format('|'.join(URLS))).match
3341
3342=== modified file 'friends/utils/time.py'
3343--- friends/utils/time.py 2013-02-05 01:11:35 +0000
3344+++ friends/utils/time.py 2013-04-16 17:53:25 +0000
3345@@ -29,6 +29,8 @@
3346 from contextlib import contextmanager
3347 from datetime import datetime, timedelta
3348
3349+from friends.errors import ignored
3350+
3351
3352 # Date time formats. Assume no microseconds and no timezone.
3353 ISO8601_FORMAT = '%Y-%m-%dT%H:%M:%S'
3354@@ -119,11 +121,8 @@
3355 # No timezone string was found.
3356 tz_offset = timedelta()
3357 for parser in PARSERS:
3358- try:
3359+ with ignored(ValueError):
3360 parsed_dt = parser(naive_t)
3361- except ValueError:
3362- pass
3363- else:
3364 break
3365 else:
3366 # Nothing matched.
3367
3368=== modified file 'tools/debug_live.py'
3369--- tools/debug_live.py 2013-04-05 00:57:02 +0000
3370+++ tools/debug_live.py 2013-04-16 17:53:25 +0000
3371@@ -33,7 +33,8 @@
3372 # Print all logs for debugging purposes
3373 initialize(debug=True, console=True)
3374
3375-from friends.utils.account import AccountManager
3376+from friends.service.dispatcher import ManageTimers
3377+from friends.utils.account import find_accounts
3378 from friends.utils.base import initialize_caches, _OperationThread
3379 from friends.utils.model import Model
3380
3381@@ -56,15 +57,14 @@
3382
3383 initialize_caches()
3384
3385+ Model.connect('row-added', row_added)
3386+
3387 found = False
3388- a = AccountManager()
3389-
3390- Model.connect('row-added', row_added)
3391-
3392- for account in a._accounts.values():
3393+ for account in find_accounts().values():
3394 if account.protocol._name == protocol.lower():
3395 found = True
3396- account.protocol(*args)
3397+ with ManageTimers() as cm:
3398+ account.protocol(*args)
3399
3400 if not found:
3401 log.error('No {} found in Ubuntu Online Accounts!'.format(protocol))
3402@@ -78,5 +78,7 @@
3403 protocol = sys.argv[1]
3404 args = sys.argv[2:]
3405
3406+ ManageTimers.callback = loop.quit
3407+ ManageTimers.timeout = 5
3408 Model.connect('notify::synchronized', setup, protocol, args)
3409 loop.run()

Subscribers

People subscribed via source and target branches