Merge lp:~andrewsomething/friends/instagram into lp:~super-friends/friends/trunk-next

Proposed by Andrew Starr-Bochicchio on 2013-04-16
Status: Merged
Merge reported by: Robert Bruce Park
Merged at revision: not available
Proposed branch: lp:~andrewsomething/friends/instagram
Merge into: lp:~super-friends/friends/trunk-next
Diff against target: 980 lines (+936/-0)
7 files modified
debian/changelog (+4/-0)
debian/control (+6/-0)
debian/friends-instagram.install (+1/-0)
friends/protocols/instagram.py (+200/-0)
friends/tests/data/instagram-full.dat (+473/-0)
friends/tests/data/instagram-login.dat (+18/-0)
friends/tests/test_instagram.py (+234/-0)
To merge this branch: bzr merge lp:~andrewsomething/friends/instagram
Reviewer Review Type Date Requested Status
Robert Bruce Park 2013-04-16 Approve on 2013-07-05
Review via email: mp+159211@code.launchpad.net

This proposal supersedes a proposal from 2013-04-12.

Description of the change

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 : Posted in a previous version of this proposal

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.

Andrew Starr-Bochicchio (andrewsomething) wrote : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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
Robert Bruce Park (robru) wrote :

Thanks for the resubmit. In terms of photos being "first class", I agree that should happen (flickr has the same problem). There's nothing to be done about it on the lp:friends side of things, it's entirely determined by how friends-app decides to display the messages. I'd like to avoid protocol-specific code in lp:friends-app, so maybe what we should do is just make the image thumbnails always visible, regardless of protocol. i've filed a bug for that here:

https://bugs.launchpad.net/ubuntu/+source/friends-app/+bug/1169679

Robert Bruce Park (robru) wrote :

Wow, testsuite is looking really nice, I can't wait to get some time to properly review this. Thursday is raring final freeze, so maybe next week!

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

Add test_error_response

Robert Bruce Park (robru) wrote :

I've done the work of rebasing this on trunk, and I've resubmitted it myself here: https://code.launchpad.net/~robru/friends/instagram/+merge/173277

Thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'debian/changelog'
2--- debian/changelog 2013-04-16 03:04:48 +0000
3+++ debian/changelog 2013-04-16 19:33:26 +0000
4@@ -1,7 +1,11 @@
5 friends (0.2.0-0ubuntu1) UNRELEASED; urgency=low
6
7+ [ Robert Bruce Park ]
8 * Version bump for the next development series.
9
10+ [Andrew Starr-Bochicchio]
11+ * Add support for Instagram (LP: #1167449).
12+
13 -- Robert Bruce Park <robert.park@canonical.com> Tue, 19 Mar 2013 15:03:58 -0700
14
15 friends (0.1.3daily13.04.10.1-0ubuntu1) raring; urgency=low
16
17=== modified file 'debian/control'
18--- debian/control 2013-02-22 00:06:42 +0000
19+++ debian/control 2013-04-16 19:33:26 +0000
20@@ -103,3 +103,9 @@
21 Depends: friends, ${misc:Depends}, ${python3:Depends}
22 Description: Social integration with the desktop - Flickr
23 Provides social networking integration with the desktop
24+
25+Package: friends-instagram
26+Architecture: all
27+Depends: friends, ${misc:Depends}, ${python3:Depends}
28+Description: Social integration with the desktop - Instagram
29+ Provides social networking integration with the desktop
30
31=== added file 'debian/friends-instagram.install'
32--- debian/friends-instagram.install 1970-01-01 00:00:00 +0000
33+++ debian/friends-instagram.install 2013-04-16 19:33:26 +0000
34@@ -0,0 +1,1 @@
35+usr/lib/python3/dist-packages/friends/protocols/instagram*
36
37=== added file 'friends/protocols/instagram.py'
38--- friends/protocols/instagram.py 1970-01-01 00:00:00 +0000
39+++ friends/protocols/instagram.py 2013-04-16 19:33:26 +0000
40@@ -0,0 +1,200 @@
41+# friends-dispatcher -- send & receive messages from any social network
42+# Copyright (C) 2013 Canonical Ltd
43+#
44+# This program is free software: you can redistribute it and/or modify
45+# it under the terms of the GNU General Public License as published by
46+# the Free Software Foundation, version 3 of the License.
47+#
48+# This program is distributed in the hope that it will be useful,
49+# but WITHOUT ANY WARRANTY; without even the implied warranty of
50+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
51+# GNU General Public License for more details.
52+#
53+# You should have received a copy of the GNU General Public License
54+# along with this program. If not, see <http://www.gnu.org/licenses/>.
55+
56+"""The Instagram protocol plugin."""
57+
58+
59+__all__ = [
60+ 'Instagram',
61+ ]
62+
63+import time
64+import logging
65+
66+from friends.utils.avatar import Avatar
67+from friends.utils.base import Base, feature
68+from friends.utils.cache import JsonCache
69+from friends.utils.http import Downloader, Uploader
70+from friends.utils.time import parsetime, iso8601utc
71+from friends.errors import FriendsError
72+
73+log = logging.getLogger(__name__)
74+
75+class Instagram(Base):
76+ _api_base = 'https://api.instagram.com/v1/{endpoint}?access_token={token}'
77+ def _whoami(self, authdata):
78+ """Identify the authenticating user."""
79+ url = self._api_base.format(
80+ endpoint='users/self',
81+ token=self._get_access_token())
82+ result = Downloader(url).get_json()
83+ self._account.user_id = result.get('data').get('id')
84+ self._account.user_name = result.get('data').get('username')
85+
86+ def _publish_entry(self, entry, stream='messages'):
87+ """Publish a single update into the Dee.SharedModel."""
88+ message_id = entry.get('id')
89+
90+ if message_id is None:
91+ # We can't do much with this entry.
92+ return
93+
94+ person = entry.get('user')
95+ nick = person.get('username')
96+ name = person.get('full_name')
97+ person_id = person.get('id')
98+ message= '%s shared a picture on Instagram.' % nick
99+ person_icon = Avatar.get_image(person.get('profile_picture'))
100+ person_url = 'http://instagram.com/' + nick
101+ picture = entry.get('images').get('thumbnail').get('url')
102+ if entry.get('caption'):
103+ desc = entry.get('caption').get('text', '')
104+ else:
105+ desc = ''
106+ url = entry.get('link')
107+ timestamp = entry.get('created_time')
108+ if timestamp is not None:
109+ timestamp = iso8601utc(parsetime(timestamp))
110+ likes = entry.get('likes').get('count')
111+ liked = entry.get('user_has_liked')
112+ location = entry.get('location', {})
113+ if location:
114+ latitude = location.get('latitude', '')
115+ longitude = location.get('longitude', '')
116+ else:
117+ latitude = 0
118+ longitude = 0
119+
120+ args = dict(
121+ message_id=message_id,
122+ message=message,
123+ stream=stream,
124+ likes=likes,
125+ sender_id=person_id,
126+ sender=name,
127+ sender_nick=nick,
128+ url=person_url,
129+ icon_uri=person_icon,
130+ link_url=url,
131+ link_picture=picture,
132+ link_desc=desc,
133+ timestamp=timestamp,
134+ liked=liked,
135+ latitude=latitude,
136+ longitude=longitude
137+ )
138+
139+ self._publish(**args)
140+
141+ # If there are any replies, publish them as well.
142+ parent_id = message_id
143+ for comment in entry.get('comments', {}).get('data', []):
144+ if comment:
145+ self._publish_comment(
146+ comment, stream='reply_to/{}'.format(parent_id))
147+ return args['url']
148+
149+ def _publish_comment(self, comment, stream):
150+ message_id = comment.get('id')
151+ if message_id is None:
152+ return
153+ message = comment.get('text', '')
154+ person = comment.get('from', {})
155+ sender_nick = person.get('username')
156+ timestamp = comment.get('created_time')
157+ if timestamp is not None:
158+ timestamp = iso8601utc(parsetime(timestamp))
159+ icon_uri = Avatar.get_image(person.get('profile_picture'))
160+ sender_id = person.get('id')
161+ sender = person.get('full_name')
162+
163+ args = dict(
164+ stream=stream,
165+ message_id=message_id,
166+ message=message,
167+ timestamp=timestamp,
168+ sender_nick=sender_nick,
169+ icon_uri=icon_uri,
170+ sender_id=sender_id,
171+ sender=sender,
172+ )
173+# print(args)
174+ self._publish(**args)
175+
176+ @feature
177+ def home(self):
178+ """Gather and publish public timeline messages."""
179+ url = self._api_base.format(
180+ endpoint='users/self/feed',
181+ token=self._get_access_token())
182+ result = Downloader(url).get_json()
183+ values = result.get('data', {})
184+ for update in values:
185+ self._publish_entry(update)
186+
187+ @feature
188+ def receive(self):
189+ """Gather and publish all incoming messages."""
190+ self.home()
191+ return self._get_n_rows()
192+
193+ def _send(self, obj_id, message, endpoint, stream='messages'):
194+ token = self._get_access_token()
195+
196+ url = self._api_base.format(endpoint=endpoint, token=token)
197+
198+ result = Downloader(
199+ url,
200+ method='POST',
201+ params=dict(access_token=token, text=message)).get_json()
202+ new_id = result.get('id')
203+ if new_id is None:
204+ raise FriendsError('Failed sending to Instagram: {!r}'.format(result))
205+ enpoint = 'media/{}/comments'.format(new_id)
206+ url = self._api_base.format(endpoint=endpoint, token=token)
207+ comment = Downloader(url, params=dict(access_token=token)).get_json()
208+ return self._publish_entry(entry=comment, stream=stream)
209+
210+ @feature
211+ def send_thread(self, obj_id, message):
212+ """Write a comment on some existing picture.
213+ """
214+ return self._send(obj_id, message, 'media/{}/comments'.format(obj_id),
215+ stream='reply_to/{}'.format(obj_id))
216+
217+ def _like(self, obj_id, endpoint, method):
218+ token = self._get_access_token()
219+ url = self._api_base.format(endpoint=endpoint, token=token)
220+
221+ if not Downloader(url, method=method,
222+ params=dict(access_token=token)).get_json():
223+ raise FriendsError('Failed to {} like {} on Instagram'.format(
224+ method, obj_id))
225+
226+ @feature
227+ def like(self, obj_id):
228+ endpoint = 'media/{}/likes'.format(obj_id)
229+ self._like(obj_id, endpoint, 'POST')
230+ self._inc_cell(obj_id, 'likes')
231+ self._set_cell(obj_id, 'liked', True)
232+ return obj_id
233+
234+ @feature
235+ def unlike(self, obj_id):
236+ endpoint = 'media/{}/likes'.format(obj_id)
237+ self._like(obj_id, endpoint, 'DELETE')
238+ self._dec_cell(obj_id, 'likes')
239+ self._set_cell(obj_id, 'liked', False)
240+ return obj_id
241
242=== added file 'friends/tests/data/instagram-full.dat'
243--- friends/tests/data/instagram-full.dat 1970-01-01 00:00:00 +0000
244+++ friends/tests/data/instagram-full.dat 2013-04-16 19:33:26 +0000
245@@ -0,0 +1,473 @@
246+{
247+ "pagination": {
248+ "next_url": "https://api.instagram.com/v1/users/self/feed?access_token=4abc&max_id=431429392441037546_46931811",
249+ "next_max_id": "431429392441037546_46931811"
250+ },
251+ "meta": {
252+ "code": 200
253+ },
254+ "data": [
255+ {
256+ "attribution": null,
257+ "tags": [],
258+ "type": "image",
259+ "location": null,
260+ "comments": {
261+ "count": 1,
262+ "data": [
263+ {
264+ "created_time": "1365680633",
265+ "text": "Wtf is that from?",
266+ "from": {
267+ "username": "ellllliottttt",
268+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_13811917_75sq_1322106636.jpg",
269+ "id": "13811917",
270+ "full_name": "Elliott Markowitz"
271+ },
272+ "id": "431682899841631793"
273+ }
274+ ]
275+ },
276+ "filter": "Normal",
277+ "created_time": "1365655801",
278+ "link": "http://instagram.com/p/X859raK8fx/",
279+ "likes": {
280+ "count": 8,
281+ "data": [
282+ {
283+ "username": "noahvargass",
284+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_1037856_75sq_1354178959.jpg",
285+ "id": "1037856",
286+ "full_name": "Noah Vargas"
287+ },
288+ {
289+ "username": "deniboring",
290+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_16828740_75sq_1345748703.jpg",
291+ "id": "16828740",
292+ "full_name": "deniboring"
293+ },
294+ {
295+ "username": "gabriel_cordero",
296+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_185127499_75sq_1340358021.jpg",
297+ "id": "185127499",
298+ "full_name": "cheeseburger pocket"
299+ },
300+ {
301+ "username": "inmyheadache",
302+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_22645548_75sq_1343529900.jpg",
303+ "id": "22645548",
304+ "full_name": "Tim F"
305+ },
306+ {
307+ "username": "ellllliottttt",
308+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_13811917_75sq_1322106636.jpg",
309+ "id": "13811917",
310+ "full_name": "Elliott Markowitz"
311+ },
312+ {
313+ "username": "eyesofsatan",
314+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_2526196_75sq_1352254863.jpg",
315+ "id": "2526196",
316+ "full_name": "Christopher Hansell"
317+ },
318+ {
319+ "username": "lakeswimming",
320+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_3610143_75sq_1363648525.jpg",
321+ "id": "3610143",
322+ "full_name": "lakeswimming"
323+ },
324+ {
325+ "username": "jtesnakis",
326+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_214679263_75sq_1346185198.jpg",
327+ "id": "214679263",
328+ "full_name": "Jonathan"
329+ }
330+ ]
331+ },
332+ "images": {
333+ "low_resolution": {
334+ "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_6.jpg",
335+ "width": 306,
336+ "height": 306
337+ },
338+ "thumbnail": {
339+ "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_5.jpg",
340+ "width": 150,
341+ "height": 150
342+ },
343+ "standard_resolution": {
344+ "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_7.jpg",
345+ "width": 612,
346+ "height": 612
347+ }
348+ },
349+ "caption": null,
350+ "user_has_liked": false,
351+ "id": "431474591469914097_223207800",
352+ "user": {
353+ "username": "joshwolp",
354+ "website": "",
355+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_223207800_75sq_1347753109.jpg",
356+ "full_name": "Josh",
357+ "bio": "",
358+ "id": "223207800"
359+ }
360+ },
361+ {
362+ "attribution": null,
363+ "tags": [
364+ "nofilter"
365+ ],
366+ "type": "image",
367+ "location": {
368+ "latitude": 40.702485554,
369+ "longitude": -73.929230548
370+ },
371+ "comments": {
372+ "count": 3,
373+ "data": [
374+ {
375+ "created_time": "1365654315",
376+ "text": "I remember pushing that little guy of the swings a few times....",
377+ "from": {
378+ "username": "squidneylol",
379+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_5917696_75sq_1336705905.jpg",
380+ "id": "5917696",
381+ "full_name": "Syd"
382+ },
383+ "id": "431462132263145102"
384+ },
385+ {
386+ "created_time": "1365665741",
387+ "text": "Stop it!!!",
388+ "from": {
389+ "username": "nightruiner",
390+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31239607_75sq_1358968326.jpg",
391+ "id": "31239607",
392+ "full_name": "meredith gaydosh"
393+ },
394+ "id": "431557973837598336"
395+ },
396+ {
397+ "created_time": "1365691283",
398+ "text": "You know I hate being held.",
399+ "from": {
400+ "username": "piratemaddi",
401+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_191388805_75sq_1341897870.jpg",
402+ "id": "191388805",
403+ "full_name": "piratemaddi"
404+ },
405+ "id": "431772240369143639"
406+ }
407+ ]
408+ },
409+ "filter": "Normal",
410+ "created_time": "1365651440",
411+ "link": "http://instagram.com/p/X8xpYwk82w/",
412+ "likes": {
413+ "count": 17,
414+ "data": [
415+ {
416+ "username": "meaghanlou",
417+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_41007792_75sq_1334443633.jpg",
418+ "id": "41007792",
419+ "full_name": "meaghanlou"
420+ },
421+ {
422+ "username": "shahwiththat",
423+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_40353923_75sq_1361330343.jpg",
424+ "id": "40353923",
425+ "full_name": "Soraya Shah"
426+ },
427+ {
428+ "username": "marsyred",
429+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_216361031_75sq_1346524176.jpg",
430+ "id": "216361031",
431+ "full_name": "marsyred"
432+ },
433+ {
434+ "username": "thewhitewizzard",
435+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_189027880_75sq_1341183134.jpg",
436+ "id": "189027880",
437+ "full_name": "Drew Mack"
438+ },
439+ {
440+ "username": "juddymagee",
441+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_8993099_75sq_1315066258.jpg",
442+ "id": "8993099",
443+ "full_name": "juddymagee"
444+ },
445+ {
446+ "username": "pixxamonster",
447+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_1672624_75sq_1364673157.jpg",
448+ "id": "1672624",
449+ "full_name": "Sonia 👓 + Dita 🐈"
450+ },
451+ {
452+ "username": "tongue_in_cheek",
453+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_5710964_75sq_1345359909.jpg",
454+ "id": "5710964",
455+ "full_name": "merrily."
456+ },
457+ {
458+ "username": "nightruiner",
459+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31239607_75sq_1358968326.jpg",
460+ "id": "31239607",
461+ "full_name": "meredith gaydosh"
462+ },
463+ {
464+ "username": "eliz_mortals",
465+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_36417303_75sq_1364179217.jpg",
466+ "id": "36417303",
467+ "full_name": "eliz_mortals"
468+ },
469+ {
470+ "username": "caranicoletti",
471+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_14162814_75sq_1359848230.jpg",
472+ "id": "14162814",
473+ "full_name": "Cara Nicoletti"
474+ }
475+ ]
476+ },
477+ "images": {
478+ "low_resolution": {
479+ "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_6.jpg",
480+ "width": 306,
481+ "height": 306
482+ },
483+ "thumbnail": {
484+ "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_5.jpg",
485+ "width": 150,
486+ "height": 150
487+ },
488+ "standard_resolution": {
489+ "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_7.jpg",
490+ "width": 612,
491+ "height": 612
492+ }
493+ },
494+ "caption": {
495+ "created_time": "1365651465",
496+ "text": "National siblings day @piratemaddi ? You mustn't've known. #nofilter",
497+ "from": {
498+ "username": "what_a_handsome_boy",
499+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_5891266_75sq_1345460082.jpg",
500+ "id": "5891266",
501+ "full_name": "Fence!"
502+ },
503+ "id": "431438224520630210"
504+ },
505+ "user_has_liked": false,
506+ "id": "431438012683111856_5891266",
507+ "user": {
508+ "username": "what_a_handsome_boy",
509+ "website": "",
510+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_5891266_75sq_1345460082.jpg",
511+ "full_name": "Fence!",
512+ "bio": "",
513+ "id": "5891266"
514+ }
515+ },
516+ {
517+ "attribution": null,
518+ "tags": [
519+ "canada",
520+ "iphone",
521+ "punklife",
522+ "acap",
523+ "doom",
524+ "crust"
525+ ],
526+ "type": "image",
527+ "location": null,
528+ "comments": {
529+ "count": 7,
530+ "data": [
531+ {
532+ "created_time": "1365651434",
533+ "text": "I have not seen him for a minute, he is a great guy!",
534+ "from": {
535+ "username": "rocktoberblood",
536+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_210506849_75sq_1355190976.jpg",
537+ "id": "210506849",
538+ "full_name": "Nate Phillips"
539+ },
540+ "id": "431437959929388541"
541+ },
542+ {
543+ "created_time": "1365652422",
544+ "text": "Saw him puke in the elevator at the Hilton when Doom played chaos.",
545+ "from": {
546+ "username": "occult_obsession",
547+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_6080835_75sq_1364154159.jpg",
548+ "id": "6080835",
549+ "full_name": "Evan Vellela"
550+ },
551+ "id": "431446251162427175"
552+ },
553+ {
554+ "created_time": "1365652900",
555+ "text": "Lol ^ who DIDN'T do this at chaos",
556+ "from": {
557+ "username": "laurababbili",
558+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_1238692_75sq_1361065720.jpg",
559+ "id": "1238692",
560+ "full_name": "Laura Babbili"
561+ },
562+ "id": "431450261906907041"
563+ },
564+ {
565+ "created_time": "1365655310",
566+ "text": "I tried to kiss the singer of cocksparrer in that elevator.",
567+ "from": {
568+ "username": "gregdaly",
569+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg",
570+ "id": "31133446",
571+ "full_name": "greg daly"
572+ },
573+ "id": "431470473175752176"
574+ },
575+ {
576+ "created_time": "1365655687",
577+ "text": "Leant that dude a 900 Marshall a time ago! Love that dude!!",
578+ "from": {
579+ "username": "nickatnightengale",
580+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_29366372_75sq_1354241971.jpg",
581+ "id": "29366372",
582+ "full_name": "Nick Poulos"
583+ },
584+ "id": "431473639539727956"
585+ },
586+ {
587+ "created_time": "1365658654",
588+ "text": "Hahaha!!",
589+ "from": {
590+ "username": "jeremyhush",
591+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_43750226_75sq_1335024423.jpg",
592+ "id": "43750226",
593+ "full_name": "Jeremy Hush"
594+ },
595+ "id": "431498525729480083"
596+ },
597+ {
598+ "created_time": "1365685226",
599+ "text": "There's an app for that",
600+ "from": {
601+ "username": "liam_wilson",
602+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_4357248_75sq_1327009309.jpg",
603+ "id": "4357248",
604+ "full_name": "Liam Wilson"
605+ },
606+ "id": "431721432141388885"
607+ }
608+ ]
609+ },
610+ "filter": "X-Pro II",
611+ "created_time": "1365650801",
612+ "link": "http://instagram.com/p/X8wbTQtd3Y/",
613+ "likes": {
614+ "count": 36,
615+ "data": [
616+ {
617+ "username": "fatbenatar",
618+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_280136224_75sq_1357073366.jpg",
619+ "id": "280136224",
620+ "full_name": "China Oxendine"
621+ },
622+ {
623+ "username": "tejleo",
624+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_10403332_75sq_1354058520.jpg",
625+ "id": "10403332",
626+ "full_name": "tejleo"
627+ },
628+ {
629+ "username": "tonyromeo13",
630+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_255933637_75sq_1360272947.jpg",
631+ "id": "255933637",
632+ "full_name": "Tony Romeo"
633+ },
634+ {
635+ "username": "libraryrat4",
636+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_17715046_75sq_1325381597.jpg",
637+ "id": "17715046",
638+ "full_name": "libraryrat4"
639+ },
640+ {
641+ "username": "boredwithapathy",
642+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_174679368_75sq_1338500455.jpg",
643+ "id": "174679368",
644+ "full_name": "Lisa McCarthy"
645+ },
646+ {
647+ "username": "justinpittney",
648+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_10976940_75sq_1361306615.jpg",
649+ "id": "10976940",
650+ "full_name": "Justin Pittney"
651+ },
652+ {
653+ "username": "nuclearhell",
654+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_212702314_75sq_1361642319.jpg",
655+ "id": "212702314",
656+ "full_name": "Rachel Whittaker"
657+ },
658+ {
659+ "username": "gregelk",
660+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_240084204_75sq_1359223473.jpg",
661+ "id": "240084204",
662+ "full_name": "Greg Elk"
663+ },
664+ {
665+ "username": "bradhasher",
666+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_283278325_75sq_1357514041.jpg",
667+ "id": "283278325",
668+ "full_name": "Brett Ellingson"
669+ },
670+ {
671+ "username": "zaimlmzim",
672+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_180060277_75sq_1363118292.jpg",
673+ "id": "180060277",
674+ "full_name": "zaimlmzim"
675+ }
676+ ]
677+ },
678+ "images": {
679+ "low_resolution": {
680+ "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_6.jpg",
681+ "width": 306,
682+ "height": 306
683+ },
684+ "thumbnail": {
685+ "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_5.jpg",
686+ "width": 150,
687+ "height": 150
688+ },
689+ "standard_resolution": {
690+ "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_7.jpg",
691+ "width": 612,
692+ "height": 612
693+ }
694+ },
695+ "caption": {
696+ "created_time": "1365650915",
697+ "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",
698+ "from": {
699+ "username": "gregdaly",
700+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg",
701+ "id": "31133446",
702+ "full_name": "greg daly"
703+ },
704+ "id": "431433605218426202"
705+ },
706+ "user_has_liked": false,
707+ "id": "431432646660578776_31133446",
708+ "user": {
709+ "username": "gregdaly",
710+ "website": "",
711+ "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg",
712+ "full_name": "greg daly",
713+ "bio": "",
714+ "id": "31133446"
715+ }
716+ }
717+ ]
718+}
719
720=== added file 'friends/tests/data/instagram-login.dat'
721--- friends/tests/data/instagram-login.dat 1970-01-01 00:00:00 +0000
722+++ friends/tests/data/instagram-login.dat 2013-04-16 19:33:26 +0000
723@@ -0,0 +1,18 @@
724+{
725+ "meta": {
726+ "code": 200
727+ },
728+ "data": {
729+ "username": "bpersons",
730+ "bio": "",
731+ "website": "",
732+ "profile_picture": "http://images.ak.instagram.com/profiles/anonymousUser.jpg",
733+ "full_name": "Bart Person",
734+ "counts": {
735+ "media": 174,
736+ "followed_by": 100,
737+ "follows": 503
738+ },
739+ "id": "801"
740+ }
741+}
742
743=== added file 'friends/tests/test_instagram.py'
744--- friends/tests/test_instagram.py 1970-01-01 00:00:00 +0000
745+++ friends/tests/test_instagram.py 2013-04-16 19:33:26 +0000
746@@ -0,0 +1,234 @@
747+# friends-dispatcher -- send & receive messages from any social network
748+# Copyright (C) 2012 Canonical Ltd
749+#
750+# This program is free software: you can redistribute it and/or modify
751+# it under the terms of the GNU General Public License as published by
752+# the Free Software Foundation, version 3 of the License.
753+#
754+# This program is distributed in the hope that it will be useful,
755+# but WITHOUT ANY WARRANTY; without even the implied warranty of
756+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
757+# GNU General Public License for more details.
758+#
759+# You should have received a copy of the GNU General Public License
760+# along with this program. If not, see <http://www.gnu.org/licenses/>.
761+
762+"""Test the Instagram plugin."""
763+
764+
765+__all__ = [
766+ 'TestInstagram',
767+ ]
768+
769+
770+import os
771+import tempfile
772+import unittest
773+import shutil
774+
775+from gi.repository import GLib
776+from pkg_resources import resource_filename
777+
778+from friends.protocols.instagram import Instagram
779+from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock
780+from friends.tests.mocks import TestModel, mock
781+from friends.tests.mocks import EDSBookClientMock, EDSSource, EDSRegistry
782+from friends.errors import ContactsError, FriendsError, AuthorizationError
783+from friends.utils.cache import JsonCache
784+
785+
786+@mock.patch('friends.utils.http._soup', mock.Mock())
787+@mock.patch('friends.utils.base.notify', mock.Mock())
788+class TestInstagram(unittest.TestCase):
789+ """Test the Instagram API."""
790+
791+ def setUp(self):
792+ self._temp_cache = tempfile.mkdtemp()
793+ self._root = JsonCache._root = os.path.join(
794+ self._temp_cache, '{}.json')
795+ self.account = FakeAccount()
796+ self.protocol = Instagram(self.account)
797+ self.protocol.source_registry = EDSRegistry()
798+
799+ def tearDown(self):
800+ TestModel.clear()
801+ shutil.rmtree(self._temp_cache)
802+
803+ def test_features(self):
804+ # The set of public features.
805+ self.assertEqual(Instagram.get_features(),
806+ ['home', 'like', 'receive', 'send_thread', 'unlike'])
807+
808+ @mock.patch('friends.utils.authentication.manager')
809+ @mock.patch('friends.utils.authentication.Accounts')
810+ @mock.patch('friends.utils.authentication.Authentication.__init__',
811+ return_value=None)
812+ @mock.patch('friends.utils.authentication.Authentication.login',
813+ return_value=dict(AccessToken='abc'))
814+ @mock.patch('friends.utils.http.Soup.Message',
815+ FakeSoupMessage('friends.tests.data', 'instagram-login.dat'))
816+ def test_successful_login(self, *mock):
817+ # Test that a successful response from instagram.com returning
818+ # the user's data, sets up the account dict correctly.
819+ self.protocol._login()
820+ self.assertEqual(self.account.access_token, 'abc')
821+ self.assertEqual(self.account.user_name, 'bpersons')
822+ self.assertEqual(self.account.user_id, '801')
823+
824+ @mock.patch('friends.utils.authentication.manager')
825+ @mock.patch('friends.utils.authentication.Accounts')
826+ @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1)
827+ @mock.patch('friends.utils.authentication.Signon.AuthSession.new')
828+ def test_login_unsuccessful_authentication(self, *mock):
829+ # The user is not already logged in, but the act of logging in fails.
830+ self.assertRaises(AuthorizationError, self.protocol._login)
831+ self.assertIsNone(self.account.access_token)
832+ self.assertIsNone(self.account.user_name)
833+
834+ @mock.patch('friends.utils.authentication.manager')
835+ @mock.patch('friends.utils.authentication.Accounts')
836+ @mock.patch('friends.utils.authentication.Authentication.login',
837+ return_value=dict(AccessToken='abc'))
838+ @mock.patch('friends.protocols.instagram.Downloader.get_json',
839+ return_value=dict(
840+ error=dict(message='Bad access token',
841+ type='OAuthException',
842+ code=190)))
843+ def test_error_response(self, *mocks):
844+ with LogMock('friends.utils.base',
845+ 'friends.protocols.instagram') as log_mock:
846+ self.assertRaises(
847+ FriendsError,
848+ self.protocol.home,
849+ )
850+ contents = log_mock.empty(trim=False)
851+ self.assertEqual(contents, 'Logging in to Instagram\n')
852+
853+ @mock.patch('friends.utils.http.Soup.Message',
854+ FakeSoupMessage('friends.tests.data', 'instagram-full.dat'))
855+ @mock.patch('friends.utils.base.Model', TestModel)
856+ @mock.patch('friends.protocols.instagram.Instagram._login',
857+ return_value=True)
858+ def test_receive(self, *mocks):
859+ # Receive the feed for a user.
860+ self.maxDiff = None
861+ self.account.access_token = 'abc'
862+ self.assertEqual(self.protocol.receive(), 14)
863+ self.assertEqual(TestModel.get_n_rows(), 14)
864+ self.assertEqual(list(TestModel.get_row(0)), [
865+ 'instagram',
866+ 88,
867+ '431474591469914097_223207800',
868+ 'messages',
869+ 'Josh',
870+ '223207800',
871+ 'joshwolp',
872+ False,
873+ '2013-04-11T04:50:01Z',
874+ 'joshwolp shared a picture on Instagram.',
875+ GLib.get_user_cache_dir() +
876+ '/friends/avatars/ca55b643e7b440762c7c6292399eed6542a84b90',
877+ 'http://instagram.com/joshwolp',
878+ 8,
879+ False,
880+ 'http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_5.jpg',
881+ '',
882+ 'http://instagram.com/p/X859raK8fx/',
883+ '',
884+ '',
885+ '',
886+ '',
887+ 0.0,
888+ 0.0,
889+ ])
890+ self.assertEqual(list(TestModel.get_row(3)), [
891+ 'instagram',
892+ 88,
893+ '431462132263145102',
894+ 'reply_to/431438012683111856_5891266',
895+ 'Syd',
896+ '5917696',
897+ 'squidneylol',
898+ False,
899+ '2013-04-11T04:25:15Z',
900+ 'I remember pushing that little guy of the swings a few times....',
901+ GLib.get_user_cache_dir() +
902+ '/friends/avatars/e61c8d91e37fec3e1dec9325fa4edc52ebeb96bb',
903+ '',
904+ 0,
905+ False,
906+ '',
907+ '',
908+ '',
909+ '',
910+ '',
911+ '',
912+ '',
913+ 0.0,
914+ 0.0,
915+ ])
916+
917+ @mock.patch('friends.protocols.instagram.Downloader')
918+ def test_send_thread(self, dload):
919+ dload().get_json.return_value = dict(id='comment_id')
920+ token = self.protocol._get_access_token = mock.Mock(
921+ return_value='abc')
922+ publish = self.protocol._publish_entry = mock.Mock(
923+ return_value='http://instagram.com/p/post_id')
924+
925+ self.assertEqual(
926+ self.protocol.send_thread('post_id', 'Some witty response!'),
927+ 'http://instagram.com/p/post_id')
928+ token.assert_called_once_with()
929+ publish.assert_called_with(entry={'id': 'comment_id'},
930+ stream='reply_to/post_id')
931+ self.assertEqual(
932+ dload.mock_calls,
933+ [mock.call(),
934+ mock.call(
935+ 'https://api.instagram.com/v1/media/post_id/comments?access_token=abc',
936+ method='POST',
937+ params=dict(
938+ access_token='abc',
939+ text='Some witty response!')),
940+ mock.call().get_json(),
941+ mock.call('https://api.instagram.com/v1/media/post_id/comments?access_token=abc',
942+ params=dict(access_token='abc')),
943+ mock.call().get_json(),
944+ ])
945+
946+ @mock.patch('friends.protocols.instagram.Downloader')
947+ def test_like(self, dload):
948+ dload().get_json.return_value = True
949+ token = self.protocol._get_access_token = mock.Mock(
950+ return_value='insta')
951+ inc_cell = self.protocol._inc_cell = mock.Mock()
952+ set_cell = self.protocol._set_cell = mock.Mock()
953+
954+ self.assertEqual(self.protocol.like('post_id'), 'post_id')
955+
956+ inc_cell.assert_called_once_with('post_id', 'likes')
957+ set_cell.assert_called_once_with('post_id', 'liked', True)
958+ token.assert_called_once_with()
959+ dload.assert_called_with(
960+ 'https://api.instagram.com/v1/media/post_id/likes?access_token=insta',
961+ method='POST',
962+ params=dict(access_token='insta'))
963+
964+ @mock.patch('friends.protocols.instagram.Downloader')
965+ def test_unlike(self, dload):
966+ dload.get_json.return_value = True
967+ token = self.protocol._get_access_token = mock.Mock(
968+ return_value='insta')
969+ dec_cell = self.protocol._dec_cell = mock.Mock()
970+ set_cell = self.protocol._set_cell = mock.Mock()
971+
972+ self.assertEqual(self.protocol.unlike('post_id'), 'post_id')
973+
974+ dec_cell.assert_called_once_with('post_id', 'likes')
975+ set_cell.assert_called_once_with('post_id', 'liked', False)
976+ token.assert_called_once_with()
977+ dload.assert_called_once_with(
978+ 'https://api.instagram.com/v1/media/post_id/likes?access_token=insta',
979+ method='DELETE',
980+ params=dict(access_token='insta'))

Subscribers

People subscribed via source and target branches