#!/usr/bin/env python # -*-*- encoding: utf-8 -*-*- # # This is the service file for the Google Photo python client. # It is used for higher level operations. # # $Id: service.py 144 2007-10-25 21:03:34Z havard.gulldahl $ # # Copyright 2007 Håvard Gulldahl # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Google PhotoService provides a human-friendly interface to Google Photo (a.k.a Picasa Web) services[1]. It extends gdata.service.GDataService and as such hides all the nasty details about authenticating, parsing and communicating with Google Photos. [1]: http://code.google.com/apis/picasaweb/gdata.html Example: import gdata.photos, gdata.photos.service pws = gdata.photos.service.PhotosService() pws.ClientLogin(username, password) #Get all albums albums = pws.GetUserFeed().entry # Get all photos in second album photos = pws.GetFeed(albums[1].GetPhotosUri()).entry # Get all tags for photos in second album and print them tags = pws.GetFeed(albums[1].GetTagsUri()).entry print [ tag.summary.text for tag in tags ] # Get all comments for the first photos in list and print them comments = pws.GetCommentFeed(photos[0].GetCommentsUri()).entry print [ c.summary.text for c in comments ] # Get a photo to work with photo = photos[0] # Update metadata # Attributes from the namespace photo.summary.text = u'A nice view from my veranda' photo.title.text = u'Verandaview.jpg' # Attributes from the namespace photo.media.keywords.text = u'Home, Long-exposure, Sunset' # Comma-separated # Adding attributes to media object # Rotate 90 degrees clockwise photo.rotation = gdata.photos.Rotation(text='90') # Submit modified photo object photo = pws.UpdatePhotoMetadata(photo) # Make sure you only modify the newly returned object, else you'll get # versioning errors. See Optimistic-concurrency # Add comment to a picture comment = pws.InsertComment(photo, u'I wish the water always was this warm') # Remove comment because it was silly print "*blush*" pws.Delete(comment.GetEditLink().href) """ __author__ = u'havard@gulldahl.no'# (Håvard Gulldahl)' #BUG: pydoc chokes on non-ascii chars in __author__ __license__ = 'Apache License v2' __version__ = '$Revision: 176 $'[11:-2] import sys, os.path, StringIO import time import gdata.service import gdata import atom.service import atom import gdata.photos SUPPORTED_UPLOAD_TYPES = ('bmp', 'jpeg', 'jpg', 'gif', 'png') UNKOWN_ERROR=1000 GPHOTOS_BAD_REQUEST=400 GPHOTOS_CONFLICT=409 GPHOTOS_INTERNAL_SERVER_ERROR=500 GPHOTOS_INVALID_ARGUMENT=601 GPHOTOS_INVALID_CONTENT_TYPE=602 GPHOTOS_NOT_AN_IMAGE=603 GPHOTOS_INVALID_KIND=604 class GooglePhotosException(Exception): def __init__(self, response): self.error_code = response['status'] self.reason = response['reason'].strip() if '' in str(response['body']): #general html message, discard it response['body'] = "" self.body = response['body'].strip() self.message = "(%(status)s) %(body)s -- %(reason)s" % response #return explicit error codes error_map = { '(12) Not an image':GPHOTOS_NOT_AN_IMAGE, 'kind: That is not one of the acceptable values': GPHOTOS_INVALID_KIND, } for msg, code in error_map.iteritems(): if self.body == msg: self.error_code = code break self.args = [self.error_code, self.reason, self.body] class PhotosService(gdata.service.GDataService): userUri = '/data/feed/api/user/%s' def __init__(self, email=None, password=None, source=None, server='picasaweb.google.com', additional_headers=None, **kwargs): """Creates a client for the Google Photos service. Args: email: string (optional) The user's email address, used for authentication. password: string (optional) The user's password. source: string (optional) The name of the user's application. server: string (optional) The name of the server to which a connection will be opened. Default value: 'picasaweb.google.com'. **kwargs: The other parameters to pass to gdata.service.GDataService constructor. """ self.email = email self.client = source gdata.service.GDataService.__init__( self, email=email, password=password, service='lh2', source=source, server=server, additional_headers=additional_headers, **kwargs) def GetFeed(self, uri, limit=None, start_index=None): """Get a feed. The results are ordered by the values of their `updated' elements, with the most recently updated entry appearing first in the feed. Arguments: uri: the uri to fetch limit (optional): the maximum number of entries to return. Defaults to what the server returns. Returns: one of gdata.photos.AlbumFeed, gdata.photos.UserFeed, gdata.photos.PhotoFeed, gdata.photos.CommentFeed, gdata.photos.TagFeed, depending on the results of the query. Raises: GooglePhotosException See: http://code.google.com/apis/picasaweb/gdata.html#Get_Album_Feed_Manual """ if limit is not None: uri += '&max-results=%s' % limit if start_index is not None: uri += '&start-index=%s' % start_index try: return self.Get(uri, converter=gdata.photos.AnyFeedFromString) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def GetEntry(self, uri, limit=None, start_index=None): """Get an Entry. Arguments: uri: the uri to the entry limit (optional): the maximum number of entries to return. Defaults to what the server returns. Returns: one of gdata.photos.AlbumEntry, gdata.photos.UserEntry, gdata.photos.PhotoEntry, gdata.photos.CommentEntry, gdata.photos.TagEntry, depending on the results of the query. Raises: GooglePhotosException """ if limit is not None: uri += '&max-results=%s' % limit if start_index is not None: uri += '&start-index=%s' % start_index try: return self.Get(uri, converter=gdata.photos.AnyEntryFromString) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def GetUserFeed(self, kind='album', user='default', limit=None): """Get user-based feed, containing albums, photos, comments or tags; defaults to albums. The entries are ordered by the values of their `updated' elements, with the most recently updated entry appearing first in the feed. Arguments: kind: the kind of entries to get, either `album', `photo', `comment' or `tag', or a python list of these. Defaults to `album'. user (optional): whose albums we're querying. Defaults to current user. limit (optional): the maximum number of entries to return. Defaults to everything the server returns. Returns: gdata.photos.UserFeed, containing appropriate Entry elements See: http://code.google.com/apis/picasaweb/gdata.html#Get_Album_Feed_Manual http://googledataapis.blogspot.com/2007/07/picasa-web-albums-adds-new-api-features.html """ if isinstance(kind, (list, tuple) ): kind = ",".join(kind) uri = '/data/feed/api/user/%s?kind=%s' % (user, kind) return self.GetFeed(uri, limit=limit) def GetTaggedPhotos(self, tag, user='default', limit=None): """Get all photos belonging to a specific user, tagged by the given keyword Arguments: tag: The tag you're looking for, e.g. `dog' user (optional): Whose images/videos you want to search, defaults to current user limit (optional): the maximum number of entries to return. Defaults to everything the server returns. Returns: gdata.photos.UserFeed containing PhotoEntry elements """ # Lower-casing because of # http://code.google.com/p/gdata-issues/issues/detail?id=194 uri = '/data/feed/api/user/%s?kind=photo&tag=%s' % (user, tag.lower()) return self.GetFeed(uri, limit) def SearchUserPhotos(self, query, user='default', limit=100): """Search through all photos for a specific user and return a feed. This will look for matches in file names and image tags (a.k.a. keywords) Arguments: query: The string you're looking for, e.g. `vacation' user (optional): The username of whose photos you want to search, defaults to current user. limit (optional): Don't return more than `limit' hits, defaults to 100 Only public photos are searched, unless you are authenticated and searching through your own photos. Returns: gdata.photos.UserFeed with PhotoEntry elements """ uri = '/data/feed/api/user/%s?kind=photo&q=%s' % (user, query) return self.GetFeed(uri, limit=limit) def SearchCommunityPhotos(self, query, limit=100): """Search through all public photos and return a feed. This will look for matches in file names and image tags (a.k.a. keywords) Arguments: query: The string you're looking for, e.g. `vacation' limit (optional): Don't return more than `limit' hits, defaults to 100 Returns: gdata.GDataFeed with PhotoEntry elements """ uri='/data/feed/api/all?q=%s' % query return self.GetFeed(uri, limit=limit) def GetContacts(self, user='default', limit=None): """Retrieve a feed that contains a list of your contacts Arguments: user: Username of the user whose contacts you want Returns gdata.photos.UserFeed, with UserEntry entries See: http://groups.google.com/group/Google-Picasa-Data-API/msg/819b0025b5ff5e38 """ uri = '/data/feed/api/user/%s/contacts?kind=user' % user return self.GetFeed(uri, limit=limit) def SearchContactsPhotos(self, user='default', search=None, limit=None): """Search over your contacts' photos and return a feed Arguments: user: Username of the user whose contacts you want search (optional): What to search for (photo title, description and keywords) Returns gdata.photos.UserFeed, with PhotoEntry elements See: http://groups.google.com/group/Google-Picasa-Data-API/msg/819b0025b5ff5e38 """ uri = '/data/feed/api/user/%s/contacts?kind=photo&q=%s' % (user, search) return self.GetFeed(uri, limit=limit) def InsertAlbum(self, title, summary, location=None, access='public', commenting_enabled='true', timestamp=None): """Add an album. Needs authentication, see self.ClientLogin() Arguments: title: Album title summary: Album summary / description access (optional): `private' or `public'. Public albums are searchable by everyone on the internet. Defaults to `public' commenting_enabled (optional): `true' or `false'. Defaults to `true'. timestamp (optional): A date and time for the album, in milliseconds since Unix epoch[1] UTC. Defaults to now. Returns: The newly created gdata.photos.AlbumEntry See: http://code.google.com/apis/picasaweb/gdata.html#Add_Album_Manual_Installed [1]: http://en.wikipedia.org/wiki/Unix_epoch """ album = gdata.photos.AlbumEntry() album.title = atom.Title(text=title, title_type='text') album.summary = atom.Summary(text=summary, summary_type='text') if location is not None: album.location = gdata.photos.Location(text=location) album.access = gdata.photos.Access(text=access) if commenting_enabled in ('true', 'false'): album.commentingEnabled = gdata.photos.CommentingEnabled(text=commenting_enabled) if timestamp is None: timestamp = '%i' % int(time.time() * 1000) album.timestamp = gdata.photos.Timestamp(text=timestamp) try: return self.Post(album, uri=self.userUri % self.email, converter=gdata.photos.AlbumEntryFromString) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def InsertPhoto(self, album_or_uri, photo, filename_or_handle, content_type='image/jpeg'): """Add a PhotoEntry Needs authentication, see self.ClientLogin() Arguments: album_or_uri: AlbumFeed or uri of the album where the photo should go photo: PhotoEntry to add filename_or_handle: A file-like object or file name where the image/video will be read from content_type (optional): Internet media type (a.k.a. mime type) of media object. Currently Google Photos supports these types: o image/bmp o image/gif o image/jpeg o image/png Images will be converted to jpeg on upload. Defaults to `image/jpeg' """ try: assert(isinstance(photo, gdata.photos.PhotoEntry)) except AssertionError: raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT, 'body':'`photo` must be a gdata.photos.PhotoEntry instance', 'reason':'Found %s, not PhotoEntry' % type(photo) }) try: majtype, mintype = content_type.split('/') assert(mintype in SUPPORTED_UPLOAD_TYPES) except (ValueError, AssertionError): raise GooglePhotosException({'status':GPHOTOS_INVALID_CONTENT_TYPE, 'body':'This is not a valid content type: %s' % content_type, 'reason':'Accepted content types: %s' % \ ['image/'+t for t in SUPPORTED_UPLOAD_TYPES] }) if isinstance(filename_or_handle, (str, unicode)) and \ os.path.exists(filename_or_handle): # it's a file name mediasource = gdata.MediaSource() mediasource.setFile(filename_or_handle, content_type) elif hasattr(filename_or_handle, 'read'):# it's a file-like resource if hasattr(filename_or_handle, 'seek'): filename_or_handle.seek(0) # rewind pointer to the start of the file # gdata.MediaSource needs the content length, so read the whole image file_handle = StringIO.StringIO(filename_or_handle.read()) name = 'image' if hasattr(filename_or_handle, 'name'): name = filename_or_handle.name mediasource = gdata.MediaSource(file_handle, content_type, content_length=file_handle.len, file_name=name) else: #filename_or_handle is not valid raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT, 'body':'`filename_or_handle` must be a path name or a file-like object', 'reason':'Found %s, not path name or object with a .read() method' % \ type(filename_or_handle) }) if isinstance(album_or_uri, (str, unicode)): # it's a uri feed_uri = album_or_uri elif hasattr(album_or_uri, 'GetFeedLink'): # it's a AlbumFeed object feed_uri = album_or_uri.GetFeedLink().href try: return self.Post(photo, uri=feed_uri, media_source=mediasource, converter=gdata.photos.PhotoEntryFromString) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def InsertPhotoSimple(self, album_or_uri, title, summary, filename_or_handle, content_type='image/jpeg', keywords=None): """Add a photo without constructing a PhotoEntry. Needs authentication, see self.ClientLogin() Arguments: album_or_uri: AlbumFeed or uri of the album where the photo should go title: Photo title summary: Photo summary / description filename_or_handle: A file-like object or file name where the image/video will be read from content_type (optional): Internet media type (a.k.a. mime type) of media object. Currently Google Photos supports these types: o image/bmp o image/gif o image/jpeg o image/png Images will be converted to jpeg on upload. Defaults to `image/jpeg' keywords (optional): a 1) comma separated string or 2) a python list() of keywords (a.k.a. tags) to add to the image. E.g. 1) `dog, vacation, happy' 2) ['dog', 'happy', 'vacation'] Returns: The newly created gdata.photos.PhotoEntry or GooglePhotosException on errors See: http://code.google.com/apis/picasaweb/gdata.html#Add_Album_Manual_Installed [1]: http://en.wikipedia.org/wiki/Unix_epoch """ metadata = gdata.photos.PhotoEntry() metadata.title=atom.Title(text=title) metadata.summary = atom.Summary(text=summary, summary_type='text') if keywords is not None: if isinstance(keywords, list): keywords = ','.join(keywords) metadata.media.keywords = gdata.media.Keywords(text=keywords) return self.InsertPhoto(album_or_uri, metadata, filename_or_handle, content_type) def UpdatePhotoMetadata(self, photo): """Update a photo's metadata. Needs authentication, see self.ClientLogin() You can update any or all of the following metadata properties: * * <media:description> * <gphoto:checksum> * <gphoto:client> * <gphoto:rotation> * <gphoto:timestamp> * <gphoto:commentingEnabled> Arguments: photo: a gdata.photos.PhotoEntry object with updated elements Returns: The modified gdata.photos.PhotoEntry Example: p = GetFeed(uri).entry[0] p.title.text = u'My new text' p.commentingEnabled.text = 'false' p = UpdatePhotoMetadata(p) It is important that you don't keep the old object around, once it has been updated. See http://code.google.com/apis/gdata/reference.html#Optimistic-concurrency """ try: return self.Put(data=photo, uri=photo.GetEditLink().href, converter=gdata.photos.PhotoEntryFromString) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def UpdatePhotoBlob(self, photo_or_uri, filename_or_handle, content_type = 'image/jpeg'): """Update a photo's binary data. Needs authentication, see self.ClientLogin() Arguments: photo_or_uri: a gdata.photos.PhotoEntry that will be updated, or a `edit-media' uri pointing to it filename_or_handle: A file-like object or file name where the image/video will be read from content_type (optional): Internet media type (a.k.a. mime type) of media object. Currently Google Photos supports these types: o image/bmp o image/gif o image/jpeg o image/png Images will be converted to jpeg on upload. Defaults to `image/jpeg' Returns: The modified gdata.photos.PhotoEntry Example: p = GetFeed(PhotoUri) p = UpdatePhotoBlob(p, '/tmp/newPic.jpg') It is important that you don't keep the old object around, once it has been updated. See http://code.google.com/apis/gdata/reference.html#Optimistic-concurrency """ try: majtype, mintype = content_type.split('/') assert(mintype in SUPPORTED_UPLOAD_TYPES) except (ValueError, AssertionError): raise GooglePhotosException({'status':GPHOTOS_INVALID_CONTENT_TYPE, 'body':'This is not a valid content type: %s' % content_type, 'reason':'Accepted content types: %s' % \ ['image/'+t for t in SUPPORTED_UPLOAD_TYPES] }) if isinstance(filename_or_handle, (str, unicode)) and \ os.path.exists(filename_or_handle): # it's a file name photoblob = gdata.MediaSource() photoblob.setFile(filename_or_handle, content_type) elif hasattr(filename_or_handle, 'read'):# it's a file-like resource if hasattr(filename_or_handle, 'seek'): filename_or_handle.seek(0) # rewind pointer to the start of the file # gdata.MediaSource needs the content length, so read the whole image file_handle = StringIO.StringIO(filename_or_handle.read()) name = 'image' if hasattr(filename_or_handle, 'name'): name = filename_or_handle.name mediasource = gdata.MediaSource(file_handle, content_type, content_length=file_handle.len, file_name=name) else: #filename_or_handle is not valid raise GooglePhotosException({'status':GPHOTOS_INVALID_ARGUMENT, 'body':'`filename_or_handle` must be a path name or a file-like object', 'reason':'Found %s, not path name or an object with .read() method' % \ type(filename_or_handle) }) if isinstance(photo_or_uri, (str, unicode)): entry_uri = photo_or_uri # it's a uri elif hasattr(photo_or_uri, 'GetEditMediaLink'): entry_uri = photo_or_uri.GetEditMediaLink().href try: return self.Put(photoblob, entry_uri, converter=gdata.photos.PhotoEntryFromString) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def InsertTag(self, photo_or_uri, tag): """Add a tag (a.k.a. keyword) to a photo. Needs authentication, see self.ClientLogin() Arguments: photo_or_uri: a gdata.photos.PhotoEntry that will be tagged, or a `post' uri pointing to it (string) tag: The tag/keyword Returns: The new gdata.photos.TagEntry Example: p = GetFeed(PhotoUri) tag = InsertTag(p, 'Beautiful sunsets') """ tag = gdata.photos.TagEntry(title=atom.Title(text=tag)) if isinstance(photo_or_uri, (str, unicode)): post_uri = photo_or_uri # it's a uri elif hasattr(photo_or_uri, 'GetEditMediaLink'): post_uri = photo_or_uri.GetPostLink().href try: return self.Post(data=tag, uri=post_uri, converter=gdata.photos.TagEntryFromString) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def InsertComment(self, photo_or_uri, comment): """Add a comment to a photo. Needs authentication, see self.ClientLogin() Arguments: photo_or_uri: a gdata.photos.PhotoEntry that is about to be commented , or a `post' uri pointing to it (string) comment: The actual comment Returns: The new gdata.photos.CommentEntry Example: p = GetFeed(PhotoUri) tag = InsertComment(p, 'OOOH! I would have loved to be there. Who's that in the back?') """ comment = gdata.photos.CommentEntry(content=atom.Content(text=comment)) if isinstance(photo_or_uri, (str, unicode)): post_uri = photo_or_uri # it's a uri elif hasattr(photo_or_uri, 'GetEditMediaLink'): post_uri = photo_or_uri.GetPostLink().href try: return self.Post(data=comment, uri=post_uri, converter=gdata.photos.CommentEntryFromString) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def Delete(self, object_or_uri, *args, **kwargs): """Delete an object. Re-implementing the GDataService.Delete method, to add some convenience. Arguments: object_or_uri: Any object that has a GetEditLink() method that returns a link, or a uri to that object. Returns: ? or GooglePhotosException on errors """ try: uri = object_or_uri.GetEditLink().href except AttributeError: uri = object_or_uri try: return gdata.service.GDataService.Delete(self, uri, *args, **kwargs) except gdata.service.RequestError, e: raise GooglePhotosException(e.args[0]) def GetSmallestThumbnail(media_thumbnail_list): """Helper function to get the smallest thumbnail of a list of gdata.media.Thumbnail. Returns gdata.media.Thumbnail """ r = {} for thumb in media_thumbnail_list: r[int(thumb.width)*int(thumb.height)] = thumb keys = r.keys() keys.sort() return r[keys[0]] def ConvertAtomTimestampToEpoch(timestamp): """Helper function to convert a timestamp string, for instance from atom:updated or atom:published, to milliseconds since Unix epoch (a.k.a. POSIX time). `2007-07-22T00:45:10.000Z' -> """ return time.mktime(time.strptime(timestamp, '%Y-%m-%dT%H:%M:%S.000Z')) ## TODO: Timezone aware