Merge lp:~robru/friends/async into lp:friends

Proposed by Robert Bruce Park
Status: Merged
Approved by: Ken VanDine
Approved revision: 86
Merged at revision: 84
Proposed branch: lp:~robru/friends/async
Merge into: lp:friends
Diff against target: 267 lines (+158/-15)
4 files modified
friends/protocols/facebook.py (+19/-7)
friends/service/dispatcher.py (+47/-2)
friends/tests/test_dispatcher.py (+20/-0)
friends/tests/test_facebook.py (+72/-6)
To merge this branch: bzr merge lp:~robru/friends/async
Reviewer Review Type Date Requested Status
Ken VanDine Approve
Review via email: mp+137696@code.launchpad.net

Description of the change

Hey Ken,

so this mostly looks like it's working, and I've added test coverage to it, however be aware that I have not actually *used* this to upload any *real* files to confirm that it *actually* works, so you should probably do that ;-)

One problem I foresee is that the upload code is raising exceptions in some places (eg, like if the client passes in a path instead of a URI, or if the URI does not refer to an existing file), and those exceptions are swallowed up and logged by the threading logic, which means there are scenarios in which neither the success nor the failure callback are ever called. We might want to catch all exceptions and then make sure that the failure callback is called, but I didn't have time for that right now because I am on my way out for lunch.

Let me know what you think of it so far and then when I get back I can clean it up to your liking.

But mostly let me know if it even works ;-)

To post a comment you must log in.
lp:~robru/friends/async updated
86. By Robert Bruce Park

Catch exceptions and call the failure callback instead of (uselessly)
raising them.

Our threading infrastructure makes exceptions really useless because
they get silently swallowed and then logged -- calling code has no
real way of checking for error conditions short of parsing the log
file in real time. So this commit (at least for facebook uploads)
catches all exceptions and calls the failure callback.

At this point I am not aware of any other possible exceptions that
could be raised, that we are not already catching and calling the
failure callback. Ie, there are no conditions known to me in which an
exception will be logged and ignored instead of the failure callback
being called.

This should be considered the model for how to implement async APIs
throughout the rest of lp:friends, and any other methods needing to
indicate error conditions back to it's calling code will have to
mimick this design.

Revision history for this message
Robert Bruce Park (robru) wrote :

Alright Ken, I fixed up that exception stuff I was talking about, so now it should always call either the success or failure callbacks no matter what. Let me know if you can think of any conditions for which this isn't true, but I think it's good.

With your approval of this approach, I'll begin adding success/failure callbacks to most other things.

In fact, we should probably find some way of merging the logging code into the failure callbacks, since there's going to be a *ton* of overlap between calls to failure() and calls to log.error().

Revision history for this message
Ken VanDine (ken-vandine) wrote :

I am merging this with one minor tweak, I adjusted the failure callback to only handle a single argument. The python-dbus docs show that the error_handler callback will be called with one argument.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'friends/protocols/facebook.py'
2--- friends/protocols/facebook.py 2012-11-23 18:55:01 +0000
3+++ friends/protocols/facebook.py 2012-12-04 00:42:20 +0000
4@@ -39,6 +39,7 @@
5 API_BASE = URL_BASE.format(subdomain='graph') + '{id}'
6 ME_URL = API_BASE.format(id='me')
7 FACEBOOK_ADDRESS_BOOK = 'friends-facebook-contacts'
8+STUB = lambda *ignore, **kwignore: None
9
10
11 log = logging.getLogger(__name__)
12@@ -273,19 +274,27 @@
13 self._unpublish(obj_id)
14
15 @feature
16- def upload(self, picture_uri, description=''):
17+ def upload(self, picture_uri, description='', success=STUB, failure=STUB):
18 # http://developers.facebook.com/docs/reference/api/photo/
19- """Upload local or remote image or video to album"""
20+ """Upload local or remote image or video to album."""
21 url = '{}/photos?access_token={}'.format(
22 ME_URL, self._get_access_token())
23- response = Uploader(
24- url, picture_uri, description,
25- picture_key='source', desc_key='message').get_json()
26+ try:
27+ response = Uploader(
28+ url, picture_uri, description,
29+ picture_key='source', desc_key='message').get_json()
30+ except Exception as err:
31+ failure(self._account.id, picture_uri, str(err))
32+ log.error(str(err))
33+ return
34 if response is None:
35- log.error('No response from upload server.')
36+ message = 'No response from upload server.'
37+ failure(self._account.id, picture_uri, message)
38+ log.error(message)
39 return
40 post_id = response.get('post_id')
41 if post_id is not None:
42+ destination_url = PERMALINK.format(id=post_id)
43 self._publish(
44 from_me=True,
45 stream='images',
46@@ -295,10 +304,13 @@
47 sender_id=self._account.user_id,
48 sender_nick=self._account.user_name,
49 timestamp=iso8601utc(int(time.time())),
50- url=PERMALINK.format(id=post_id),
51+ url=destination_url,
52 icon_uri=Avatar.get_image(
53 API_BASE.format(id=self._account.user_id) +
54 '/picture?type=large'))
55+ success(self._account.id, picture_uri, destination_url)
56+ else:
57+ failure(self._account.id, picture_uri, str(response))
58
59 def _fetch_contacts(self):
60 """Retrieve a list of up to 1,000 Facebook friends."""
61
62=== modified file 'friends/service/dispatcher.py'
63--- friends/service/dispatcher.py 2012-11-27 16:05:30 +0000
64+++ friends/service/dispatcher.py 2012-12-04 00:42:20 +0000
65@@ -217,7 +217,7 @@
66
67 @dbus.service.method(DBUS_INTERFACE, in_signature='sss')
68 def Upload(self, account_id, uri, description):
69- """Upload an image to the specified account_id..
70+ """Upload an image to the specified account_id.
71
72 It takes three arguments, all strings. The URI option is
73 parsed by GFile and thus seamlessly supports uploading from
74@@ -228,7 +228,7 @@
75 obj = dbus.SessionBus().get_object(DBUS_INTERFACE,
76 '/com/canonical/friends/Service')
77 service = dbus.Interface(obj, DBUS_INTERFACE)
78- service.SendReply(
79+ service.Upload(
80 '6/twitter',
81 'file:///path/to/image.png',
82 'A beautiful picture.')
83@@ -242,6 +242,51 @@
84 else:
85 log.error('Could not find account: {}'.format(account_id))
86
87+ @dbus.service.method(DBUS_INTERFACE,
88+ in_signature='sss',
89+ out_signature='sss',
90+ async_callbacks=('success','failure'))
91+ def UploadAsync(self, account_id, uri, description, success, failure):
92+ """Upload an image to the specified account_id.
93+
94+ It takes five arguments, three strings and two callback
95+ functions. The URI option is parsed by GFile and thus
96+ seamlessly supports uploading from http:// URLs as well as
97+ file:// paths. You'll also need a working glib mainloop for
98+ the below example to function correctly.
99+
100+ example:
101+ import dbus
102+ obj = dbus.SessionBus().get_object(DBUS_INTERFACE,
103+ '/com/canonical/friends/Service')
104+ service = dbus.Interface(obj, DBUS_INTERFACE)
105+
106+ def success(account_id, uri, destination_url):
107+ print(
108+ '{} successfully uploaded to {}.'.format(
109+ uri, destination_url))
110+
111+ def failure(account_id, uri, message):
112+ print('{} failed to upload: {}'.format(uri, message))
113+
114+ service.UploadAsync(
115+ '6/twitter',
116+ 'file:///path/to/image.png',
117+ 'A beautiful picture.'
118+ reply_handler=success,
119+ error_handler=failure)
120+ """
121+ if not self.online:
122+ failure(account_id, uri, 'No internet connection available.')
123+ return
124+ log.debug('Uploading {} to {}'.format(uri, account_id))
125+ account = self.account_manager.get(account_id)
126+ if account is not None:
127+ account.protocol('upload', uri, description, success, failure)
128+ else:
129+ failure(account_id, uri, 'Invalid account_id.')
130+ log.error('Could not find account: {}'.format(account_id))
131+
132 @dbus.service.method(DBUS_INTERFACE, out_signature='s')
133 def GetFeatures(self, protocol_name):
134 """Returns a list of features supported by service as json string.
135
136=== modified file 'friends/tests/test_dispatcher.py'
137--- friends/tests/test_dispatcher.py 2012-11-27 16:05:30 +0000
138+++ friends/tests/test_dispatcher.py 2012-12-04 00:42:20 +0000
139@@ -178,6 +178,26 @@
140 self.assertEqual(self.log_mock.empty(),
141 'Uploading file://path/to/image.png to 2/facebook\n')
142
143+ def test_upload_async(self):
144+ account = mock.Mock()
145+ self.dispatcher.account_manager = mock.Mock()
146+ self.dispatcher.account_manager.get.return_value = account
147+
148+ success = mock.Mock()
149+ failure = mock.Mock()
150+
151+ self.dispatcher.UploadAsync('2/facebook',
152+ 'file://path/to/image.png',
153+ 'A thousand words',
154+ success=success,
155+ failure=failure)
156+ self.dispatcher.account_manager.get.assert_called_once_with('2/facebook')
157+ account.protocol.assert_called_once_with(
158+ 'upload', 'file://path/to/image.png', 'A thousand words', success, failure)
159+
160+ self.assertEqual(self.log_mock.empty(),
161+ 'Uploading file://path/to/image.png to 2/facebook\n')
162+
163 def test_get_features(self):
164 self.assertEqual(json.loads(self.dispatcher.GetFeatures('facebook')),
165 ['contacts', 'delete', 'home', 'like', 'receive',
166
167=== modified file 'friends/tests/test_facebook.py'
168--- friends/tests/test_facebook.py 2012-11-25 06:37:04 +0000
169+++ friends/tests/test_facebook.py 2012-12-04 00:42:20 +0000
170@@ -338,6 +338,36 @@
171 message_id='234125',
172 sender=None)
173
174+ @mock.patch('friends.protocols.facebook.Uploader.get_json',
175+ return_value=dict(post_id='234125'))
176+ @mock.patch('friends.protocols.facebook.time.time',
177+ return_value=1352209748.1254)
178+ def test_upload_async_local(self, *mocks):
179+ token = self.protocol._get_access_token = mock.Mock(
180+ return_value='face')
181+ publish = self.protocol._publish = mock.Mock()
182+ success = mock.Mock()
183+ failure = mock.Mock()
184+
185+ src = 'file://' + resource_filename('friends.tests.data', 'ubuntu.png')
186+ self.protocol.upload(src, 'This is Ubuntu!', success, failure)
187+ token.assert_called_once_with()
188+ success.assert_called_once_with(
189+ 'faker/than fake', src, 'https://www.facebook.com/234125')
190+ self.assertEqual(failure.call_count, 0)
191+
192+ publish.assert_called_once_with(
193+ sender_nick=None,
194+ stream='images',
195+ url='https://www.facebook.com/234125',
196+ timestamp='2012-11-06T13:49:08Z',
197+ sender_id=None,
198+ from_me=True,
199+ icon_uri='',
200+ message='This is Ubuntu!',
201+ message_id='234125',
202+ sender=None)
203+
204 @mock.patch('friends.utils.http._soup')
205 @mock.patch('friends.protocols.facebook.Uploader._build_request',
206 return_value=None)
207@@ -349,9 +379,29 @@
208 publish = self.protocol._publish = mock.Mock()
209
210 src = 'file:///tmp/a/non-existant/path'
211- self.assertRaises(
212- ValueError, self.protocol.upload, src, 'There is no spoon')
213- token.assert_called_once_with()
214+ self.protocol.upload(src, 'There is no spoon')
215+ token.assert_called_once_with()
216+
217+ self.assertFalse(publish.called)
218+
219+ @mock.patch('friends.utils.http._soup')
220+ @mock.patch('friends.protocols.facebook.Uploader._build_request',
221+ return_value=None)
222+ @mock.patch('friends.protocols.facebook.time.time',
223+ return_value=1352209748.1254)
224+ def test_upload_async_missing(self, *mocks):
225+ token = self.protocol._get_access_token = mock.Mock(
226+ return_value='face')
227+ publish = self.protocol._publish = mock.Mock()
228+ success = mock.Mock()
229+ failure = mock.Mock()
230+
231+ src = 'file:///tmp/a/non-existant/path'
232+ self.protocol.upload(src, 'There is no spoon', success, failure)
233+ token.assert_called_once_with()
234+ failure.assert_called_once_with(
235+ 'faker/than fake', src, 'Failed to build this HTTP request.')
236+ self.assertEqual(success.call_count, 0)
237
238 self.assertFalse(publish.called)
239
240@@ -362,9 +412,25 @@
241 publish = self.protocol._publish = mock.Mock()
242
243 src = resource_filename('friends.tests.data', 'ubuntu.png')
244- self.assertRaises(
245- GLib.GError, self.protocol.upload, src, 'There is no spoon')
246- token.assert_called_once_with()
247+ self.protocol.upload(src, 'There is no spoon')
248+ token.assert_called_once_with()
249+
250+ self.assertFalse(publish.called)
251+
252+ @mock.patch('friends.utils.http._soup')
253+ def test_upload_async_not_uri(self, *mocks):
254+ token = self.protocol._get_access_token = mock.Mock(
255+ return_value='face')
256+ publish = self.protocol._publish = mock.Mock()
257+ success = mock.Mock()
258+ failure = mock.Mock()
259+
260+ src = resource_filename('friends.tests.data', 'ubuntu.png')
261+ self.protocol.upload(src, 'There is no spoon', success, failure)
262+ token.assert_called_once_with()
263+ failure.assert_called_once_with(
264+ 'faker/than fake', src, 'Operation not supported')
265+ self.assertEqual(success.call_count, 0)
266
267 self.assertFalse(publish.called)
268

Subscribers

People subscribed via source and target branches