1
2
3
4 '''A FlickrAPI interface.
5
6 The main functionality can be found in the `flickrapi.FlickrAPI`
7 class.
8
9 See `the FlickrAPI homepage`_ for more info.
10
11 .. _`the FlickrAPI homepage`: http://stuvel.eu/projects/flickrapi
12 '''
13
14 __version__ = '1.4.4'
15 __all__ = ('FlickrAPI', 'IllegalArgumentException', 'FlickrError',
16 'CancelUpload', 'XMLNode', 'set_log_level', '__version__')
17 __author__ = u'Sybren St\u00fcvel'.encode('utf-8')
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47 import urllib
48 import urllib2
49 import os.path
50 import logging
51 import webbrowser
52
53
54 try:
55 from hashlib import md5
56 except ImportError:
57 from md5 import md5
58
59 from flickrapi.tokencache import (TokenCache, SimpleTokenCache,
60 LockingTokenCache)
61 from flickrapi.xmlnode import XMLNode
62 from flickrapi.multipart import Part, Multipart, FilePart
63 from flickrapi.exceptions import (IllegalArgumentException, FlickrError,
64 CancelUpload)
65 from flickrapi.cache import SimpleCache
66 from flickrapi import reportinghttp
67
68 logging.basicConfig()
69 LOG = logging.getLogger(__name__)
70 LOG.setLevel(logging.INFO)
74 '''Encodes all Unicode strings in the dictionary to UTF-8. Converts
75 all other objects to regular strings.
76
77 Returns a copy of the dictionary, doesn't touch the original.
78 '''
79
80 result = {}
81
82 for (key, value) in dictionary.iteritems():
83 if isinstance(value, unicode):
84 value = value.encode('utf-8')
85 else:
86 value = str(value)
87 result[key] = value
88
89 return result
90
93 '''Method decorator for debugging method calls.
94
95 Using this automatically sets the log level to DEBUG.
96 '''
97
98 LOG.setLevel(logging.DEBUG)
99
100 def debugged(*args, **kwargs):
101 LOG.debug("Call: %s(%s, %s)" % (method.__name__, args,
102 kwargs))
103 result = method(*args, **kwargs)
104 LOG.debug("\tResult: %s" % result)
105 return result
106
107 return debugged
108
109
110
111
112 rest_parsers = {}
116 '''Method decorator, use this to mark a function as the parser for
117 REST as returned by Flickr.
118 '''
119
120 def decorate_parser(method):
121 rest_parsers[format] = method
122 return method
123
124 return decorate_parser
125
143
144 return decorated
145 return decorator
146
149 """Encapsulates Flickr functionality.
150
151 Example usage::
152
153 flickr = flickrapi.FlickrAPI(api_key)
154 photos = flickr.photos_search(user_id='73509078@N00', per_page='10')
155 sets = flickr.photosets_getList(user_id='73509078@N00')
156 """
157
158 flickr_host = "api.flickr.com"
159 flickr_rest_form = "/services/rest/"
160 flickr_auth_form = "/services/auth/"
161 flickr_upload_form = "/services/upload/"
162 flickr_replace_form = "/services/replace/"
163
164 - def __init__(self, api_key, secret=None, username=None,
165 token=None, format='etree', store_token=True,
166 cache=False):
167 """Construct a new FlickrAPI instance for a given API key
168 and secret.
169
170 api_key
171 The API key as obtained from Flickr.
172
173 secret
174 The secret belonging to the API key.
175
176 username
177 Used to identify the appropriate authentication token for a
178 certain user.
179
180 token
181 If you already have an authentication token, you can give
182 it here. It won't be stored on disk by the FlickrAPI instance.
183
184 format
185 The response format. Use either "xmlnode" or "etree" to get a
186 parsed response, or use any response format supported by Flickr
187 to get an unparsed response from method calls. It's also possible
188 to pass the ``format`` parameter on individual calls.
189
190 store_token
191 Disables the on-disk token cache if set to False (default is True).
192 Use this to ensure that tokens aren't read nor written to disk, for
193 example in web applications that store tokens in cookies.
194
195 cache
196 Enables in-memory caching of FlickrAPI calls - set to ``True`` to
197 use. If you don't want to use the default settings, you can
198 instantiate a cache yourself too:
199
200 >>> f = FlickrAPI(api_key='123')
201 >>> f.cache = SimpleCache(timeout=5, max_entries=100)
202 """
203
204 self.api_key = api_key
205 self.secret = secret
206 self.default_format = format
207
208 self.__handler_cache = {}
209
210 if token:
211
212 self.token_cache = SimpleTokenCache()
213 self.token_cache.token = token
214 elif not store_token:
215
216 self.token_cache = SimpleTokenCache()
217 else:
218
219 self.token_cache = TokenCache(api_key, username)
220
221 if cache:
222 self.cache = SimpleCache()
223 else:
224 self.cache = None
225
227 '''Returns a string representation of this object.'''
228
229 return '[FlickrAPI for key "%s"]' % self.api_key
230 __str__ = __repr__
231
233 '''Returns a list of method names as supported by the Flickr
234 API. Used for tab completion in IPython.
235 '''
236
237 try:
238 rsp = self.reflection_getMethods(format='etree')
239 except FlickrError:
240 return None
241
242 def tr(name):
243 '''Translates Flickr names to something that can be called
244 here.
245
246 >>> tr(u'flickr.photos.getInfo')
247 u'photos_getInfo'
248 '''
249
250 return name[7:].replace('.', '_')
251
252 return [tr(m.text) for m in rsp.getiterator('method')]
253
254 @rest_parser('xmlnode')
256 '''Parses a REST XML response from Flickr into an XMLNode object.'''
257
258 rsp = XMLNode.parse(rest_xml, store_xml=True)
259 if rsp['stat'] == 'ok':
260 return rsp
261
262 err = rsp.err[0]
263 raise FlickrError(u'Error: %(code)s: %(msg)s' % err)
264
265 @rest_parser('etree')
267 '''Parses a REST XML response from Flickr into
268 an ElementTree object.'''
269
270 try:
271 import xml.etree.ElementTree as ElementTree
272 except ImportError:
273
274 try:
275 import elementtree.ElementTree as ElementTree
276 except ImportError:
277 raise ImportError("You need to install "
278 "ElementTree for using the etree format")
279
280 rsp = ElementTree.fromstring(rest_xml)
281 if rsp.attrib['stat'] == 'ok':
282 return rsp
283
284 err = rsp.find('err')
285 raise FlickrError(u'Error: %(code)s: %(msg)s' % err.attrib)
286
287 - def sign(self, dictionary):
288 """Calculate the flickr signature for a set of params.
289
290 data
291 a hash of all the params and values to be hashed, e.g.
292 ``{"api_key":"AAAA", "auth_token":"TTTT", "key":
293 u"value".encode('utf-8')}``
294
295 """
296
297 data = [self.secret]
298 for key in sorted(dictionary.keys()):
299 data.append(key)
300 datum = dictionary[key]
301 if isinstance(datum, unicode):
302 raise IllegalArgumentException("No Unicode allowed, "
303 "argument %s (%r) should have "
304 "been UTF-8 by now"
305 % (key, datum))
306 data.append(datum)
307 md5_hash = md5(''.join(data))
308 return md5_hash.hexdigest()
309
311 '''URL encodes the data in the dictionary, and signs it using the
312 given secret, if a secret was given.
313 '''
314
315 dictionary = make_utf8(dictionary)
316 if self.secret:
317 dictionary['api_sig'] = self.sign(dictionary)
318 return urllib.urlencode(dictionary)
319
321 """Handle all the regular Flickr API calls.
322
323 Example::
324
325 flickr.auth_getFrob(api_key="AAAAAA")
326 etree = flickr.photos_getInfo(photo_id='1234')
327 etree = flickr.photos_getInfo(photo_id='1234', format='etree')
328 xmlnode = flickr.photos_getInfo(photo_id='1234', format='xmlnode')
329 json = flickr.photos_getInfo(photo_id='1234', format='json')
330 """
331
332
333 if attrib.startswith('_'):
334 raise AttributeError("No such attribute '%s'" % attrib)
335
336
337 method = "flickr." + attrib.replace("_", ".")
338 if method in self.__handler_cache:
339 return self.__handler_cache[method]
340
341 def handler(**args):
342 '''Dynamically created handler for a Flickr API call'''
343
344 if self.token_cache.token and not self.secret:
345 raise ValueError("Auth tokens cannot be used without "
346 "API secret")
347
348
349 defaults = {'method': method,
350 'auth_token': self.token_cache.token,
351 'api_key': self.api_key,
352 'format': self.default_format}
353
354 args = self.__supply_defaults(args, defaults)
355
356 return self.__wrap_in_parser(self.__flickr_call,
357 parse_format=args['format'], **args)
358
359 handler.method = method
360 self.__handler_cache[method] = handler
361 return handler
362
364 '''Returns a new dictionary containing ``args``, augmented with defaults
365 from ``defaults``.
366
367 Defaults can be overridden, or completely removed by setting the
368 appropriate value in ``args`` to ``None``.
369
370 >>> f = FlickrAPI('123')
371 >>> f._FlickrAPI__supply_defaults(
372 ... {'foo': 'bar', 'baz': None, 'token': None},
373 ... {'baz': 'foobar', 'room': 'door'})
374 {'foo': 'bar', 'room': 'door'}
375 '''
376
377 result = args.copy()
378 for key, default_value in defaults.iteritems():
379
380 if key not in args:
381 result[key] = default_value
382
383 for key, value in result.copy().iteritems():
384
385
386 if result[key] is None:
387 del result[key]
388
389 return result
390
392 '''Performs a Flickr API call with the given arguments. The method name
393 itself should be passed as the 'method' parameter.
394
395 Returns the unparsed data from Flickr::
396
397 data = self.__flickr_call(method='flickr.photos.getInfo',
398 photo_id='123', format='rest')
399 '''
400
401 LOG.debug("Calling %s" % kwargs)
402
403 post_data = self.encode_and_sign(kwargs)
404
405
406 if self.cache and self.cache.get(post_data):
407 return self.cache.get(post_data)
408
409 url = "https://" + self.flickr_host + self.flickr_rest_form
410 flicksocket = urllib2.urlopen(url, post_data)
411 reply = flicksocket.read()
412 flicksocket.close()
413
414
415 if self.cache is not None:
416 self.cache.set(post_data, reply)
417
418 return reply
419
421 '''Wraps a method call in a parser.
422
423 The parser will be looked up by the ``parse_format`` specifier. If
424 there is a parser and ``kwargs['format']`` is set, it's set to
425 ``rest``, and the response of the method is parsed before it's
426 returned.
427 '''
428
429
430
431 if parse_format in rest_parsers and 'format' in kwargs:
432 kwargs['format'] = 'rest'
433
434 LOG.debug('Wrapping call %s(self, %s, %s)' % (wrapped_method, args,
435 kwargs))
436 data = wrapped_method(*args, **kwargs)
437
438
439 if parse_format not in rest_parsers:
440 return data
441
442
443 parser = rest_parsers[parse_format]
444 return parser(self, data)
445
447 """Return the authorization URL to get a token.
448
449 This is the URL the app will launch a browser toward if it
450 needs a new token.
451
452 perms
453 "read", "write", or "delete"
454 frob
455 picked up from an earlier call to FlickrAPI.auth_getFrob()
456
457 """
458
459 encoded = self.encode_and_sign({"api_key": self.api_key,
460 "frob": frob,
461 "perms": perms})
462
463 return "https://%s%s?%s" % (self.flickr_host,
464 self.flickr_auth_form, encoded)
465
467 '''Returns the web login URL to forward web users to.
468
469 perms
470 "read", "write", or "delete"
471 '''
472
473 encoded = self.encode_and_sign({"api_key": self.api_key,
474 "perms": perms})
475
476 return "https://%s%s?%s" % (self.flickr_host,
477 self.flickr_auth_form, encoded)
478
500
501 - def upload(self, filename, callback=None, **kwargs):
502 """Upload a file to flickr.
503
504 Be extra careful you spell the parameters correctly, or you will
505 get a rather cryptic "Invalid Signature" error on the upload!
506
507 Supported parameters:
508
509 filename
510 name of a file to upload
511 callback
512 method that gets progress reports
513 title
514 title of the photo
515 description
516 description a.k.a. caption of the photo
517 tags
518 space-delimited list of tags, ``'''tag1 tag2 "long
519 tag"'''``
520 is_public
521 "1" or "0" for a public resp. private photo
522 is_friend
523 "1" or "0" whether friends can see the photo while it's
524 marked as private
525 is_family
526 "1" or "0" whether family can see the photo while it's
527 marked as private
528 content_type
529 Set to "1" for Photo, "2" for Screenshot, or "3" for Other.
530 hidden
531 Set to "1" to keep the photo in global search results, "2"
532 to hide from public searches.
533 format
534 The response format. You can only choose between the
535 parsed responses or 'rest' for plain REST.
536
537 The callback method should take two parameters:
538 ``def callback(progress, done)``
539
540 Progress is a number between 0 and 100, and done is a boolean
541 that's true only when the upload is done.
542 """
543
544 return self.__upload_to_form(self.flickr_upload_form,
545 filename, callback, **kwargs)
546
547 - def replace(self, filename, photo_id, callback=None, **kwargs):
548 """Replace an existing photo.
549
550 Supported parameters:
551
552 filename
553 name of a file to upload
554 photo_id
555 the ID of the photo to replace
556 callback
557 method that gets progress reports
558 format
559 The response format. You can only choose between the
560 parsed responses or 'rest' for plain REST. Defaults to the
561 format passed to the constructor.
562
563 The callback parameter has the same semantics as described in the
564 ``upload`` function.
565 """
566
567 if not photo_id:
568 raise IllegalArgumentException("photo_id must be specified")
569
570 kwargs['photo_id'] = photo_id
571 return self.__upload_to_form(self.flickr_replace_form,
572 filename, callback, **kwargs)
573
614
616 '''Sends a Multipart object to an URL.
617
618 Returns the resulting unparsed XML from Flickr.
619 '''
620
621 LOG.debug("Uploading to %s" % url)
622 request = urllib2.Request(url)
623 request.add_data(str(body))
624
625 (header, value) = body.header()
626 request.add_header(header, value)
627
628 if not progress_callback:
629
630
631 response = urllib2.urlopen(request)
632 return response.read()
633
634 def __upload_callback(percentage, done, seen_header=[False]):
635 '''Filters out the progress report on the HTTP header'''
636
637
638
639 if seen_header[0]:
640 return progress_callback(percentage, done)
641
642
643 if done:
644 seen_header[0] = True
645
646 response = reportinghttp.urlopen(request, __upload_callback)
647 return response.read()
648
650 '''Lets the user validate the frob by launching a browser to
651 the Flickr website.
652 '''
653
654 auth_url = self.auth_url(perms, frob)
655 try:
656 browser = webbrowser.get()
657 except webbrowser.Error:
658 if 'BROWSER' not in os.environ:
659 raise
660 browser = webbrowser.GenericBrowser(os.environ['BROWSER'])
661
662 browser.open(auth_url, True, True)
663
665 """Get a token either from the cache, or make a new one from
666 the frob.
667
668 This first attempts to find a token in the user's token cache
669 on disk. If that token is present and valid, it is returned by
670 the method.
671
672 If that fails (or if the token is no longer valid based on
673 flickr.auth.checkToken) a new frob is acquired. If an auth_callback
674 method has been specified it will be called. Otherwise the frob is
675 validated by having the user log into flickr (with a browser).
676
677 To get a proper token, follow these steps:
678 - Store the result value of this method call
679 - Give the user a way to signal the program that he/she
680 has authorized it, for example show a button that can be
681 pressed.
682 - Wait for the user to signal the program that the
683 authorization was performed, but only if there was no
684 cached token.
685 - Call flickrapi.get_token_part_two(...) and pass it the
686 result value you stored.
687
688 The newly minted token is then cached locally for the next
689 run.
690
691 perms
692 "read", "write", or "delete"
693 auth_callback
694 method to be called if authorization is needed. When not
695 passed, ``self.validate_frob(...)`` is called. You can
696 call this method yourself from the callback method too.
697
698 If authorization should be blocked, pass
699 ``auth_callback=False``.
700
701 The auth_callback method should take ``(frob, perms)`` as
702 parameters.
703
704 An example::
705
706 (token, frob) = flickr.get_token_part_one(perms='write')
707 if not token:
708 raw_input("Press ENTER after you authorized this program")
709 flickr.get_token_part_two((token, frob))
710
711 Also take a look at ``authenticate_console(perms)``.
712 """
713
714
715
716 authenticate = self.validate_frob
717 if auth_callback is not None:
718 if hasattr(auth_callback, '__call__'):
719
720 authenticate = auth_callback
721 elif auth_callback is False:
722 authenticate = None
723 else:
724
725 raise ValueError('Invalid value for auth_callback: %s'
726 % auth_callback)
727
728
729 token = self.token_cache.token
730 frob = None
731
732
733 if token:
734 LOG.debug("Trying cached token '%s'" % token)
735 try:
736 rsp = self.auth_checkToken(auth_token=token, format='xmlnode')
737
738
739 tokenPerms = rsp.auth[0].perms[0].text
740 if tokenPerms == "read" and perms != "read":
741 token = None
742 elif tokenPerms == "write" and perms == "delete":
743 token = None
744 except FlickrError:
745 LOG.debug("Cached token invalid")
746 self.token_cache.forget()
747 token = None
748
749
750 if not token:
751
752 if not authenticate:
753 raise FlickrError('Authentication required but '
754 'blocked using auth_callback=False')
755
756
757 LOG.debug("Getting frob for new token")
758 rsp = self.auth_getFrob(auth_token=None, format='xmlnode')
759
760 frob = rsp.frob[0].text
761 authenticate(frob, perms)
762
763 return (token, frob)
764
766 """Part two of getting a token,
767 see ``get_token_part_one(...)`` for details."""
768
769
770 if token:
771 LOG.debug("get_token_part_two: no need, token already there")
772 self.token_cache.token = token
773 return token
774
775 LOG.debug("get_token_part_two: "
776 "getting a new token for frob '%s'" % frob)
777
778 return self.get_token(frob)
779
781 '''Gets the token given a certain frob. Used by ``get_token_part_two`` and
782 by the web authentication method.
783 '''
784
785
786 rsp = self.auth_getToken(frob=frob, auth_token=None, format='xmlnode')
787
788 token = rsp.auth[0].token[0].text
789 LOG.debug("get_token: new token '%s'" % token)
790
791
792 self.token_cache.token = token
793
794 return token
795
797 '''Performs the authentication, assuming a console program.
798
799 Gets the token, if needed starts the browser and waits for the user to
800 press ENTER before continuing.
801
802 See ``get_token_part_one(...)`` for an explanation of the
803 parameters.
804 '''
805
806 (token, frob) = self.get_token_part_one(perms, auth_callback)
807 if not token:
808 raw_input("Press ENTER after you authorized this program")
809
810 self.get_token_part_two((token, frob))
811
812 @require_format('etree')
814 '''Calls 'method' with page=0, page=1 etc. until the total
815 number of pages has been visited. Yields the photos
816 returned.
817
818 Assumes that ``method(page=n, **params).findall('*/photos')``
819 results in a list of photos, and that the toplevel element of
820 the result contains a 'pages' attribute with the total number
821 of pages.
822 '''
823
824 page = 1
825 total = 1
826 while page <= total:
827
828 LOG.debug('Calling %s(page=%i of %i, %s)' %
829 (method.func_name, page, total, params))
830 rsp = method(page=page, **params)
831
832 photoset = rsp.getchildren()[0]
833 total = int(photoset.get('pages'))
834
835 photos = rsp.findall('*/photo')
836
837
838 for photo in photos:
839 yield photo
840
841
842 page += 1
843
844 @require_format('etree')
845 - def walk_set(self, photoset_id, per_page=50, **kwargs):
846 '''walk_set(self, photoset_id, per_page=50, ...) -> \
847 generator, yields each photo in a single set.
848
849 :Parameters:
850 photoset_id
851 the photoset ID
852 per_page
853 the number of photos that are fetched in one call to
854 Flickr.
855
856 Other arguments can be passed, as documented in the
857 flickr.photosets.getPhotos_ API call in the Flickr API
858 documentation, except for ``page`` because all pages will be
859 returned eventually.
860
861 .. _flickr.photosets.getPhotos:
862 http://www.flickr.com/services/api/flickr.photosets.getPhotos.html
863
864 Uses the ElementTree format, incompatible with other formats.
865 '''
866
867 return self.__data_walker(self.photosets_getPhotos,
868 photoset_id=photoset_id,
869 per_page=per_page, **kwargs)
870
871 @require_format('etree')
872 - def walk(self, per_page=50, **kwargs):
873 '''walk(self, user_id=..., tags=..., ...) -> generator, \
874 yields each photo in a search query result
875
876 Accepts the same parameters as flickr.photos.search_ API call,
877 except for ``page`` because all pages will be returned
878 eventually.
879
880 .. _flickr.photos.search:
881 http://www.flickr.com/services/api/flickr.photos.search.html
882
883 Also see `walk_set`.
884 '''
885
886 return self.__data_walker(self.photos_search,
887 per_page=per_page, **kwargs)
888
891 '''Sets the log level of the logger used by the FlickrAPI module.
892
893 >>> import flickrapi
894 >>> import logging
895 >>> flickrapi.set_log_level(logging.INFO)
896 '''
897
898 import flickrapi.tokencache
899
900 LOG.setLevel(level)
901 flickrapi.tokencache.LOG.setLevel(level)
902
903
904 if __name__ == "__main__":
905 print "Running doctests"
906 import doctest
907 doctest.testmod()
908 print "Tests OK"
909