PageRenderTime 51ms CodeModel.GetById 20ms app.highlight 25ms RepoModel.GetById 1ms app.codeStats 0ms

/gdata/photos/service.py

http://radioappz.googlecode.com/
Python | 680 lines | 615 code | 10 blank | 55 comment | 2 complexity | 6ac8258b43b46a835fd77db4efdbc619 MD5 | raw file
  1#!/usr/bin/env python
  2# -*-*- encoding: utf-8 -*-*-
  3#
  4# This is the service file for the Google Photo python client.
  5# It is used for higher level operations.
  6#
  7# $Id: service.py 144 2007-10-25 21:03:34Z havard.gulldahl $
  8#
  9# Copyright 2007 H?vard Gulldahl 
 10#
 11# Licensed under the Apache License, Version 2.0 (the "License");
 12# you may not use this file except in compliance with the License.
 13# You may obtain a copy of the License at
 14#
 15#      http://www.apache.org/licenses/LICENSE-2.0
 16#
 17# Unless required by applicable law or agreed to in writing, software
 18# distributed under the License is distributed on an "AS IS" BASIS,
 19# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 20# See the License for the specific language governing permissions and
 21# limitations under the License.
 22
 23"""Google PhotoService provides a human-friendly interface to
 24Google Photo (a.k.a Picasa Web) services[1].
 25
 26It extends gdata.service.GDataService and as such hides all the
 27nasty details about authenticating, parsing and communicating with
 28Google Photos. 
 29
 30[1]: http://code.google.com/apis/picasaweb/gdata.html
 31
 32Example:
 33  import gdata.photos, gdata.photos.service
 34  pws = gdata.photos.service.PhotosService()
 35  pws.ClientLogin(username, password)
 36  #Get all albums
 37  albums = pws.GetUserFeed().entry
 38  # Get all photos in second album
 39  photos = pws.GetFeed(albums[1].GetPhotosUri()).entry
 40  # Get all tags for photos in second album and print them
 41  tags = pws.GetFeed(albums[1].GetTagsUri()).entry
 42  print [ tag.summary.text for tag in tags ]
 43  # Get all comments for the first photos in list and print them
 44  comments = pws.GetCommentFeed(photos[0].GetCommentsUri()).entry
 45  print [ c.summary.text for c in comments ]
 46
 47  # Get a photo to work with
 48  photo = photos[0]
 49  # Update metadata
 50
 51  # Attributes from the <gphoto:*> namespace
 52  photo.summary.text = u'A nice view from my veranda'
 53  photo.title.text = u'Verandaview.jpg'
 54
 55  # Attributes from the <media:*> namespace
 56  photo.media.keywords.text = u'Home, Long-exposure, Sunset' # Comma-separated
 57
 58  # Adding attributes to media object
 59
 60  # Rotate 90 degrees clockwise
 61  photo.rotation = gdata.photos.Rotation(text='90') 
 62
 63  # Submit modified photo object
 64  photo = pws.UpdatePhotoMetadata(photo)
 65  
 66  # Make sure you only modify the newly returned object, else you'll get
 67  # versioning errors. See Optimistic-concurrency
 68
 69  # Add comment to a picture
 70  comment = pws.InsertComment(photo, u'I wish the water always was this warm')
 71
 72  # Remove comment because it was silly
 73  print "*blush*"
 74  pws.Delete(comment.GetEditLink().href)
 75
 76"""
 77
 78__author__ = u'havard@gulldahl.no'# (H?vard Gulldahl)' #BUG: pydoc chokes on non-ascii chars in __author__
 79__license__ = 'Apache License v2'
 80__version__ = '$Revision: 176 $'[11:-2] 
 81
 82
 83import sys, os.path, StringIO
 84import time
 85import gdata.service
 86import gdata
 87import atom.service
 88import atom
 89import gdata.photos
 90
 91SUPPORTED_UPLOAD_TYPES = ('bmp', 'jpeg', 'jpg', 'gif', 'png')
 92
 93UNKOWN_ERROR=1000
 94GPHOTOS_BAD_REQUEST=400
 95GPHOTOS_CONFLICT=409
 96GPHOTOS_INTERNAL_SERVER_ERROR=500
 97GPHOTOS_INVALID_ARGUMENT=601
 98GPHOTOS_INVALID_CONTENT_TYPE=602
 99GPHOTOS_NOT_AN_IMAGE=603
100GPHOTOS_INVALID_KIND=604
101
102class GooglePhotosException(Exception):
103  def __init__(self, response):
104
105    self.error_code = response['status']
106    self.reason = response['reason'].strip()
107    if '<html>' in str(response['body']): #general html message, discard it
108      response['body'] = ""
109    self.body = response['body'].strip()
110    self.message = "(%(status)s) %(body)s -- %(reason)s" % response
111
112    #return explicit error codes
113    error_map = { '(12) Not an image':GPHOTOS_NOT_AN_IMAGE,
114                  'kind: That is not one of the acceptable values':
115                      GPHOTOS_INVALID_KIND,
116                  
117                }
118    for msg, code in error_map.iteritems():
119      if self.body == msg:
120        self.error_code = code
121        break
122    self.args = [self.error_code, self.reason, self.body]
123
124class PhotosService(gdata.service.GDataService):
125  userUri = '/data/feed/api/user/%s'
126  
127  def __init__(self, email=None, password=None, source=None,
128               server='picasaweb.google.com', additional_headers=None,
129               **kwargs):
130    """Creates a client for the Google Photos service.
131
132    Args:
133      email: string (optional) The user's email address, used for
134          authentication.
135      password: string (optional) The user's password.
136      source: string (optional) The name of the user's application.
137      server: string (optional) The name of the server to which a connection
138          will be opened. Default value: 'picasaweb.google.com'.
139      **kwargs: The other parameters to pass to gdata.service.GDataService
140          constructor.
141    """
142    self.email = email
143    self.client = source
144    gdata.service.GDataService.__init__(
145        self, email=email, password=password, service='lh2', source=source,
146        server=server, additional_headers=additional_headers, **kwargs)
147
148  def GetFeed(self, uri, limit=None, start_index=None):
149    """Get a feed.
150
151     The results are ordered by the values of their `updated' elements,
152     with the most recently updated entry appearing first in the feed.
153    
154    Arguments:
155    uri: the uri to fetch
156    limit (optional): the maximum number of entries to return. Defaults to what
157      the server returns.
158     
159    Returns:
160    one of gdata.photos.AlbumFeed,
161           gdata.photos.UserFeed,
162           gdata.photos.PhotoFeed,
163           gdata.photos.CommentFeed,
164           gdata.photos.TagFeed,
165      depending on the results of the query.
166    Raises:
167    GooglePhotosException
168
169    See:
170    http://code.google.com/apis/picasaweb/gdata.html#Get_Album_Feed_Manual
171    """
172    if limit is not None:
173      uri += '&max-results=%s' % limit
174    if start_index is not None:
175      uri += '&start-index=%s' % start_index
176    try:
177      return self.Get(uri, converter=gdata.photos.AnyFeedFromString)
178    except gdata.service.RequestError, e:
179      raise GooglePhotosException(e.args[0])
180
181  def GetEntry(self, uri, limit=None, start_index=None):
182    """Get an Entry.
183
184    Arguments:
185    uri: the uri to the entry
186    limit (optional): the maximum number of entries to return. Defaults to what
187      the server returns.
188     
189    Returns:
190    one of gdata.photos.AlbumEntry,
191           gdata.photos.UserEntry,
192           gdata.photos.PhotoEntry,
193           gdata.photos.CommentEntry,
194           gdata.photos.TagEntry,
195      depending on the results of the query.
196    Raises:
197    GooglePhotosException
198    """
199    if limit is not None:
200      uri += '&max-results=%s' % limit
201    if start_index is not None:
202      uri += '&start-index=%s' % start_index
203    try:
204      return self.Get(uri, converter=gdata.photos.AnyEntryFromString)
205    except gdata.service.RequestError, e:
206      raise GooglePhotosException(e.args[0])
207      
208  def GetUserFeed(self, kind='album', user='default', limit=None):
209    """Get user-based feed, containing albums, photos, comments or tags;
210      defaults to albums.
211
212    The entries are ordered by the values of their `updated' elements,
213    with the most recently updated entry appearing first in the feed.
214    
215    Arguments:
216    kind: the kind of entries to get, either `album', `photo',
217      `comment' or `tag', or a python list of these. Defaults to `album'.
218    user (optional): whose albums we're querying. Defaults to current user.
219    limit (optional): the maximum number of entries to return.
220      Defaults to everything the server returns.
221
222     
223    Returns:
224    gdata.photos.UserFeed, containing appropriate Entry elements
225
226    See:
227    http://code.google.com/apis/picasaweb/gdata.html#Get_Album_Feed_Manual
228    http://googledataapis.blogspot.com/2007/07/picasa-web-albums-adds-new-api-features.html
229    """
230    if isinstance(kind, (list, tuple) ):
231      kind = ",".join(kind)
232    
233    uri = '/data/feed/api/user/%s?kind=%s' % (user, kind)
234    return self.GetFeed(uri, limit=limit)
235  
236  def GetTaggedPhotos(self, tag, user='default', limit=None):
237    """Get all photos belonging to a specific user, tagged by the given keyword
238
239    Arguments:
240    tag: The tag you're looking for, e.g. `dog'
241    user (optional): Whose images/videos you want to search, defaults
242      to current user
243    limit (optional): the maximum number of entries to return.
244      Defaults to everything the server returns.
245
246    Returns:
247    gdata.photos.UserFeed containing PhotoEntry elements
248    """
249    # Lower-casing because of
250    #   http://code.google.com/p/gdata-issues/issues/detail?id=194
251    uri = '/data/feed/api/user/%s?kind=photo&tag=%s' % (user, tag.lower())
252    return self.GetFeed(uri, limit)
253
254  def SearchUserPhotos(self, query, user='default', limit=100):
255    """Search through all photos for a specific user and return a feed.
256    This will look for matches in file names and image tags (a.k.a. keywords)
257
258    Arguments:
259    query: The string you're looking for, e.g. `vacation'
260    user (optional): The username of whose photos you want to search, defaults
261      to current user.
262    limit (optional): Don't return more than `limit' hits, defaults to 100
263
264    Only public photos are searched, unless you are authenticated and
265    searching through your own photos.
266
267    Returns:
268    gdata.photos.UserFeed with PhotoEntry elements
269    """
270    uri = '/data/feed/api/user/%s?kind=photo&q=%s' % (user, query)
271    return self.GetFeed(uri, limit=limit)
272
273  def SearchCommunityPhotos(self, query, limit=100):
274    """Search through all public photos and return a feed.
275    This will look for matches in file names and image tags (a.k.a. keywords)
276
277    Arguments:
278    query: The string you're looking for, e.g. `vacation'
279    limit (optional): Don't return more than `limit' hits, defaults to 100
280
281    Returns:
282    gdata.GDataFeed with PhotoEntry elements
283    """
284    uri='/data/feed/api/all?q=%s' % query
285    return self.GetFeed(uri, limit=limit)
286
287  def GetContacts(self, user='default', limit=None):
288    """Retrieve a feed that contains a list of your contacts
289
290    Arguments:
291    user: Username of the user whose contacts you want
292
293    Returns
294    gdata.photos.UserFeed, with UserEntry entries
295
296    See:
297    http://groups.google.com/group/Google-Picasa-Data-API/msg/819b0025b5ff5e38
298    """
299    uri = '/data/feed/api/user/%s/contacts?kind=user' % user
300    return self.GetFeed(uri, limit=limit)
301
302  def SearchContactsPhotos(self, user='default', search=None, limit=None):
303    """Search over your contacts' photos and return a feed
304
305    Arguments:
306    user: Username of the user whose contacts you want
307    search (optional): What to search for (photo title, description and keywords)
308
309    Returns
310    gdata.photos.UserFeed, with PhotoEntry elements
311
312    See:
313    http://groups.google.com/group/Google-Picasa-Data-API/msg/819b0025b5ff5e38
314    """
315
316    uri = '/data/feed/api/user/%s/contacts?kind=photo&q=%s' % (user, search)
317    return self.GetFeed(uri, limit=limit)
318
319  def InsertAlbum(self, title, summary, location=None, access='public',
320    commenting_enabled='true', timestamp=None):
321    """Add an album.
322
323    Needs authentication, see self.ClientLogin()
324
325    Arguments:
326    title: Album title 
327    summary: Album summary / description
328    access (optional): `private' or `public'. Public albums are searchable
329      by everyone on the internet. Defaults to `public'
330    commenting_enabled (optional): `true' or `false'. Defaults to `true'.
331    timestamp (optional): A date and time for the album, in milliseconds since
332      Unix epoch[1] UTC. Defaults to now.
333
334    Returns:
335    The newly created gdata.photos.AlbumEntry
336
337    See:
338    http://code.google.com/apis/picasaweb/gdata.html#Add_Album_Manual_Installed
339
340    [1]: http://en.wikipedia.org/wiki/Unix_epoch
341    """
342    album = gdata.photos.AlbumEntry()
343    album.title = atom.Title(text=title, title_type='text')
344    album.summary = atom.Summary(text=summary, summary_type='text')
345    if location is not None:
346      album.location = gdata.photos.Location(text=location)
347    album.access = gdata.photos.Access(text=access)
348    if commenting_enabled in ('true', 'false'):
349      album.commentingEnabled = gdata.photos.CommentingEnabled(text=commenting_enabled)
350    if timestamp is None:
351      timestamp = '%i' % int(time.time() * 1000)
352    album.timestamp = gdata.photos.Timestamp(text=timestamp)
353    try:
354      return self.Post(album, uri=self.userUri % self.email,
355      converter=gdata.photos.AlbumEntryFromString)
356    except gdata.service.RequestError, e:
357      raise GooglePhotosException(e.args[0])
358
359  def InsertPhoto(self, album_or_uri, photo, filename_or_handle,
360    content_type='image/jpeg'):
361    """Add a PhotoEntry
362
363    Needs authentication, see self.ClientLogin()
364
365    Arguments:
366    album_or_uri: AlbumFeed or uri of the album where the photo should go
367    photo: PhotoEntry to add
368    filename_or_handle: A file-like object or file name where the image/video
369      will be read from
370    content_type (optional): Internet media type (a.k.a. mime type) of
371      media object. Currently Google Photos supports these types:
372       o image/bmp
373       o image/gif
374       o image/jpeg
375       o image/png
376       
377      Images will be converted to jpeg on upload. Defaults to `image/jpeg'
378
379    """
380
381    try:
382      assert(isinstance(photo, gdata.photos.PhotoEntry))
383    except AssertionError:
384      raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT,
385        'body':'`photo` must be a gdata.photos.PhotoEntry instance',
386        'reason':'Found %s, not PhotoEntry' % type(photo)
387        })
388    try:
389      majtype, mintype = content_type.split('/')
390      assert(mintype in SUPPORTED_UPLOAD_TYPES)
391    except (ValueError, AssertionError):
392      raise GooglePhotosException({'status':GPHOTOS_INVALID_CONTENT_TYPE,
393        'body':'This is not a valid content type: %s' % content_type,
394        'reason':'Accepted content types: %s' % \
395          ['image/'+t for t in SUPPORTED_UPLOAD_TYPES]
396        })
397    if isinstance(filename_or_handle, (str, unicode)) and \
398      os.path.exists(filename_or_handle): # it's a file name
399      mediasource = gdata.MediaSource()
400      mediasource.setFile(filename_or_handle, content_type)
401    elif hasattr(filename_or_handle, 'read'):# it's a file-like resource
402      if hasattr(filename_or_handle, 'seek'):
403        filename_or_handle.seek(0) # rewind pointer to the start of the file
404      # gdata.MediaSource needs the content length, so read the whole image 
405      file_handle = StringIO.StringIO(filename_or_handle.read()) 
406      name = 'image'
407      if hasattr(filename_or_handle, 'name'):
408        name = filename_or_handle.name
409      mediasource = gdata.MediaSource(file_handle, content_type,
410        content_length=file_handle.len, file_name=name)
411    else: #filename_or_handle is not valid
412      raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT,
413        'body':'`filename_or_handle` must be a path name or a file-like object',
414        'reason':'Found %s, not path name or object with a .read() method' % \
415          type(filename_or_handle)
416        })
417    
418    if isinstance(album_or_uri, (str, unicode)): # it's a uri
419      feed_uri = album_or_uri
420    elif hasattr(album_or_uri, 'GetFeedLink'): # it's a AlbumFeed object
421      feed_uri = album_or_uri.GetFeedLink().href
422  
423    try:
424      return self.Post(photo, uri=feed_uri, media_source=mediasource,
425        converter=gdata.photos.PhotoEntryFromString)
426    except gdata.service.RequestError, e:
427      raise GooglePhotosException(e.args[0])
428  
429  def InsertPhotoSimple(self, album_or_uri, title, summary, filename_or_handle,
430      content_type='image/jpeg', keywords=None):
431    """Add a photo without constructing a PhotoEntry.
432
433    Needs authentication, see self.ClientLogin()
434
435    Arguments:
436    album_or_uri: AlbumFeed or uri of the album where the photo should go
437    title: Photo title
438    summary: Photo summary / description
439    filename_or_handle: A file-like object or file name where the image/video
440      will be read from
441    content_type (optional): Internet media type (a.k.a. mime type) of
442      media object. Currently Google Photos supports these types:
443       o image/bmp
444       o image/gif
445       o image/jpeg
446       o image/png
447       
448      Images will be converted to jpeg on upload. Defaults to `image/jpeg'
449    keywords (optional): a 1) comma separated string or 2) a python list() of
450      keywords (a.k.a. tags) to add to the image.
451      E.g. 1) `dog, vacation, happy' 2) ['dog', 'happy', 'vacation']
452    
453    Returns:
454    The newly created gdata.photos.PhotoEntry or GooglePhotosException on errors
455
456    See:
457    http://code.google.com/apis/picasaweb/gdata.html#Add_Album_Manual_Installed
458    [1]: http://en.wikipedia.org/wiki/Unix_epoch
459    """
460    
461    metadata = gdata.photos.PhotoEntry()
462    metadata.title=atom.Title(text=title)
463    metadata.summary = atom.Summary(text=summary, summary_type='text')
464    if keywords is not None:
465      if isinstance(keywords, list):
466        keywords = ','.join(keywords)
467      metadata.media.keywords = gdata.media.Keywords(text=keywords)
468    return self.InsertPhoto(album_or_uri, metadata, filename_or_handle,
469      content_type)
470
471  def UpdatePhotoMetadata(self, photo):
472    """Update a photo's metadata. 
473
474     Needs authentication, see self.ClientLogin()
475
476     You can update any or all of the following metadata properties:
477      * <title>
478      * <media:description>
479      * <gphoto:checksum>
480      * <gphoto:client>
481      * <gphoto:rotation>
482      * <gphoto:timestamp>
483      * <gphoto:commentingEnabled>
484
485      Arguments:
486      photo: a gdata.photos.PhotoEntry object with updated elements
487
488      Returns:
489      The modified gdata.photos.PhotoEntry
490
491      Example:
492      p = GetFeed(uri).entry[0]
493      p.title.text = u'My new text'
494      p.commentingEnabled.text = 'false'
495      p = UpdatePhotoMetadata(p)
496
497      It is important that you don't keep the old object around, once
498      it has been updated. See
499      http://code.google.com/apis/gdata/reference.html#Optimistic-concurrency
500      """
501    try:
502      return self.Put(data=photo, uri=photo.GetEditLink().href,
503      converter=gdata.photos.PhotoEntryFromString)
504    except gdata.service.RequestError, e:
505      raise GooglePhotosException(e.args[0])
506
507  
508  def UpdatePhotoBlob(self, photo_or_uri, filename_or_handle,
509                      content_type = 'image/jpeg'):
510    """Update a photo's binary data.
511
512    Needs authentication, see self.ClientLogin()
513
514    Arguments:
515    photo_or_uri: a gdata.photos.PhotoEntry that will be updated, or a
516      `edit-media' uri pointing to it
517    filename_or_handle:  A file-like object or file name where the image/video
518      will be read from
519    content_type (optional): Internet media type (a.k.a. mime type) of
520      media object. Currently Google Photos supports these types:
521       o image/bmp
522       o image/gif
523       o image/jpeg
524       o image/png
525    Images will be converted to jpeg on upload. Defaults to `image/jpeg'
526
527    Returns:
528    The modified gdata.photos.PhotoEntry
529
530    Example:
531    p = GetFeed(PhotoUri)
532    p = UpdatePhotoBlob(p, '/tmp/newPic.jpg')
533
534    It is important that you don't keep the old object around, once
535    it has been updated. See
536    http://code.google.com/apis/gdata/reference.html#Optimistic-concurrency
537    """
538
539    try:  
540      majtype, mintype = content_type.split('/')
541      assert(mintype in SUPPORTED_UPLOAD_TYPES)
542    except (ValueError, AssertionError):
543      raise GooglePhotosException({'status':GPHOTOS_INVALID_CONTENT_TYPE,
544        'body':'This is not a valid content type: %s' % content_type,
545        'reason':'Accepted content types: %s' % \
546          ['image/'+t for t in SUPPORTED_UPLOAD_TYPES]
547        })
548    
549    if isinstance(filename_or_handle, (str, unicode)) and \
550      os.path.exists(filename_or_handle): # it's a file name
551      photoblob = gdata.MediaSource()
552      photoblob.setFile(filename_or_handle, content_type)
553    elif hasattr(filename_or_handle, 'read'):# it's a file-like resource
554      if hasattr(filename_or_handle, 'seek'):
555        filename_or_handle.seek(0) # rewind pointer to the start of the file
556      # gdata.MediaSource needs the content length, so read the whole image 
557      file_handle = StringIO.StringIO(filename_or_handle.read()) 
558      name = 'image'
559      if hasattr(filename_or_handle, 'name'):
560        name = filename_or_handle.name
561      mediasource = gdata.MediaSource(file_handle, content_type,
562        content_length=file_handle.len, file_name=name)
563    else: #filename_or_handle is not valid
564      raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT,
565        'body':'`filename_or_handle` must be a path name or a file-like object',
566        'reason':'Found %s, not path name or an object with .read() method' % \
567          type(filename_or_handle)
568        })
569    
570    if isinstance(photo_or_uri, (str, unicode)):
571      entry_uri = photo_or_uri # it's a uri
572    elif hasattr(photo_or_uri, 'GetEditMediaLink'):
573      entry_uri = photo_or_uri.GetEditMediaLink().href
574    try:
575      return self.Put(photoblob, entry_uri,
576          converter=gdata.photos.PhotoEntryFromString)
577    except gdata.service.RequestError, e:
578      raise GooglePhotosException(e.args[0])
579
580  def InsertTag(self, photo_or_uri, tag):
581    """Add a tag (a.k.a. keyword) to a photo.
582
583    Needs authentication, see self.ClientLogin()
584
585    Arguments:
586    photo_or_uri: a gdata.photos.PhotoEntry that will be tagged, or a
587      `post' uri pointing to it
588    (string) tag: The tag/keyword
589
590    Returns:
591    The new gdata.photos.TagEntry
592
593    Example:
594    p = GetFeed(PhotoUri)
595    tag = InsertTag(p, 'Beautiful sunsets')
596
597    """
598    tag = gdata.photos.TagEntry(title=atom.Title(text=tag))
599    if isinstance(photo_or_uri, (str, unicode)):
600      post_uri = photo_or_uri # it's a uri
601    elif hasattr(photo_or_uri, 'GetEditMediaLink'):
602      post_uri = photo_or_uri.GetPostLink().href
603    try:
604      return self.Post(data=tag, uri=post_uri,
605      converter=gdata.photos.TagEntryFromString)
606    except gdata.service.RequestError, e:
607      raise GooglePhotosException(e.args[0])
608
609                  
610  def InsertComment(self, photo_or_uri, comment):
611    """Add a comment to a photo.
612
613    Needs authentication, see self.ClientLogin()
614
615    Arguments:
616    photo_or_uri: a gdata.photos.PhotoEntry that is about to be commented
617      , or a `post' uri pointing to it
618    (string) comment: The actual comment
619
620    Returns:
621    The new gdata.photos.CommentEntry
622
623    Example:
624    p = GetFeed(PhotoUri)
625    tag = InsertComment(p, 'OOOH! I would have loved to be there.
626      Who's that in the back?')
627
628    """
629    comment = gdata.photos.CommentEntry(content=atom.Content(text=comment))
630    if isinstance(photo_or_uri, (str, unicode)):
631      post_uri = photo_or_uri # it's a uri
632    elif hasattr(photo_or_uri, 'GetEditMediaLink'):
633      post_uri = photo_or_uri.GetPostLink().href
634    try:
635      return self.Post(data=comment, uri=post_uri,
636        converter=gdata.photos.CommentEntryFromString)
637    except gdata.service.RequestError, e:
638      raise GooglePhotosException(e.args[0])
639
640  def Delete(self, object_or_uri, *args, **kwargs):
641    """Delete an object.
642
643    Re-implementing the GDataService.Delete method, to add some
644    convenience.
645
646    Arguments:
647    object_or_uri: Any object that has a GetEditLink() method that
648      returns a link, or a uri to that object.
649
650    Returns:
651    ? or GooglePhotosException on errors
652    """
653    try:
654      uri = object_or_uri.GetEditLink().href
655    except AttributeError:
656      uri = object_or_uri
657    try:
658      return gdata.service.GDataService.Delete(self, uri, *args, **kwargs)
659    except gdata.service.RequestError, e:
660      raise GooglePhotosException(e.args[0])
661
662def GetSmallestThumbnail(media_thumbnail_list):
663  """Helper function to get the smallest thumbnail of a list of
664    gdata.media.Thumbnail.
665  Returns gdata.media.Thumbnail """
666  r = {}
667  for thumb in media_thumbnail_list:
668      r[int(thumb.width)*int(thumb.height)] = thumb
669  keys = r.keys()
670  keys.sort()
671  return r[keys[0]]
672
673def ConvertAtomTimestampToEpoch(timestamp):
674  """Helper function to convert a timestamp string, for instance
675    from atom:updated or atom:published, to milliseconds since Unix epoch
676    (a.k.a. POSIX time).
677
678    `2007-07-22T00:45:10.000Z' -> """
679  return time.mktime(time.strptime(timestamp, '%Y-%m-%dT%H:%M:%S.000Z'))
680  ## TODO: Timezone aware