Package pysmug :: Module smugmug
[hide private]
[frames] | no frames]

Source Code for Module pysmug.smugmug

  1  # Copyright (c) 2008 Brian Zimmer <bzimmer@ziclix.com> 
  2  # 
  3  # Permission is hereby granted, free of charge, to any person obtaining a copy of 
  4  # this software and associated documentation files (the "Software"), to deal in 
  5  # the Software without restriction, including without limitation the rights to 
  6  # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 
  7  # of the Software, and to permit persons to whom the Software is furnished to do 
  8  # so, subject to the following conditions: 
  9  # 
 10  # The above copyright notice and this permission notice shall be included in all 
 11  # copies or substantial portions of the Software. 
 12  # 
 13  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
 14  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 15  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
 16  # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
 17  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
 18  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
 19  # SOFTWARE. 
 20   
 21  import os 
 22  import md5 
 23  import pprint 
 24  import pycurl 
 25  import urllib 
 26  import logging 
 27  import cStringIO 
 28  from itertools import islice 
 29   
 30  try: 
 31    from cjson import decode as jsondecode 
 32  except ImportError: 
 33    from simplejson import loads as jsondecode 
 34   
 35  from pysmug import __version__ 
 36  from pysmug.methods import methods as _methods 
 37   
 38  _userAgent = "pysmug(%s)" % (__version__) 
 39   
 40  _curlinfo = ( 
 41    ("total-time", pycurl.TOTAL_TIME), 
 42    ("upload-speed", pycurl.SPEED_UPLOAD), 
 43    ("download-speed", pycurl.SPEED_DOWNLOAD), 
 44  ) 
 45   
46 -class SmugMugException(Exception):
47 """Representation of a SmugMug exception.""" 48
49 - def __init__(self, message, code=0):
50 super(SmugMugException, self).__init__(message) 51 self.code = code
52
53 - def __str__(self):
54 return "%s (code=%d)" % (super(SmugMugException, self).__str__(), self.code)
55 __repr__ = __str__
56
57 -class HTTPException(SmugMugException):
58 """An exception thrown during http(s) communication.""" 59 pass
60
61 -class SmugBase(object):
62 """Abstract functionality for SmugMug API clients.""" 63
64 - def __init__(self, sessionId=None, protocol="https"):
65 self.protocol = protocol 66 """Communication protocol -- either C{http} or C{https}""" 67 self.sessionId = sessionId 68 """Session id from smugmug."""
69
70 - def __getattr__(self, method):
71 """Construct a dynamic handler for the SmugMug API.""" 72 # Refuse to act as a proxy for unimplemented special methods 73 if method.startswith('__'): 74 raise AttributeError("no such attribute '%s'" % (method)) 75 return self._make_handler(method)
76
77 - def _make_handler(self, method):
78 method = "smugmug." + method.replace("_", ".") 79 80 if method not in _methods: 81 raise SmugMugException("no such smugmug method '%s'" % (method)) 82 83 def smugmug(*args, **kwargs): 84 """Dynamically created SmugMug function call.""" 85 if args: 86 raise SmugMugException("smugmug methods take no arguments, only named parameters") 87 defaults = {"method": method, "SessionID":self.sessionId} 88 for key, value in defaults.iteritems(): 89 if key not in kwargs: 90 kwargs[key] = value 91 # remove a default by assigning None 92 if key in kwargs and kwargs[key] is None: 93 del kwargs[key] 94 if "SessionID" in kwargs and kwargs["SessionID"] is None: 95 raise SmugMugException("not authenticated -- no valid session id") 96 query = urllib.urlencode(kwargs) 97 url = "%s://api.smugmug.com/services/api/json/1.2.1/?%s" % (self.protocol, query) 98 c = self._new_connection(url, kwargs) 99 return self._perform(c)
100 101 return smugmug
102
103 - def _new_connection(self, url, args):
104 """Prepare a new connection. 105 106 Create a new connection setting up the query string, 107 user agent header, response buffer and ssl parameters. 108 109 @param url: complete query string with parameters already encoded 110 @param args: arguments passed to method to be used for later callbacks 111 """ 112 c = pycurl.Curl() 113 c.args = args 114 c.setopt(c.URL, url) 115 logging.debug(url) 116 c.setopt(c.USERAGENT, _userAgent) 117 c.response = cStringIO.StringIO() 118 c.setopt(c.WRITEFUNCTION, c.response.write) 119 # for SSL 120 c.setopt(c.SSL_VERIFYPEER, False) 121 return c
122
123 - def _handle_response(self, c):
124 """Handle the response from SmugMug. 125 126 This method decodes the JSON response and checks for any error 127 condition. It additionally adds a C{Statistics} item to the response 128 which contains upload & download times. 129 130 @type c: PycURL C{Curl} 131 @param c: a completed connection 132 @return: a dictionary of results corresponding to the SmugMug response 133 @raise SmugMugException: if an error exists in the response 134 """ 135 code = c.getinfo(c.HTTP_CODE) 136 if not code == 200: 137 raise HTTPException(c.errstr(), code) 138 139 #### HACK #### 140 # for some reason the response from smugmug 141 # is encoded incorrectly 142 json = c.response.getvalue().replace("\/", "/") 143 ############## 144 145 logging.debug(json) 146 resp = jsondecode(json) 147 if not resp["stat"] == "ok": 148 raise SmugMugException(resp["message"], resp["code"]) 149 resp["Statistics"] = dict((key, c.getinfo(const)) for (key, const) in _curlinfo) 150 return resp
151
152 - def _perform(self, c):
153 pass
154
155 - def batch(self, protocol=None):
156 """Return an instance of a batch-oriented SmugMug client.""" 157 return SmugBatch(self.sessionId, protocol or self.protocol)
158
159 - def images_upload(self, AlbumID=None, ImageID=None, Data=None, FileName=None, **kwargs):
160 """Upload the corresponding image. 161 162 B{One of ImageID or AlbumID must be present, but not both.} 163 164 @param Data: the binary data of the image 165 @param ImageID: the id of the image to replace 166 @param AlbumID: the name of the album in which to add the photo 167 @param FileName: the name of the file 168 """ 169 if (ImageID is not None) and (AlbumID is not None): 170 raise SmugMugException("must set only one of AlbumID or ImageID") 171 172 if not Data: 173 if not (FileName or os.path.exists(FileName)): 174 raise SmugMugException("one of FileName or Data must be non-None") 175 Data = open(FileName, "rb").read() 176 177 filename = os.path.split(FileName)[-1] if FileName else "" 178 fingerprint = md5.new(Data).hexdigest() 179 image = cStringIO.StringIO(Data) 180 url = "%s://upload.smugmug.com/%s" % (self.protocol, filename) 181 182 headers = [ 183 "Host: upload.smugmug.com", 184 "Content-MD5: " + fingerprint, 185 "X-Smug-Version: 1.2.1", 186 "X-Smug-ResponseType: JSON", 187 "X-Smug-AlbumID: " + str(AlbumID) if AlbumID else "X-Smug-ImageID: " + str(ImageID), 188 "X-Smug-FileName: " + filename, 189 "X-Smug-SessionID: " + self.sessionId, 190 ] 191 for (k, v) in kwargs: 192 # Caption, Keywords, Latitude, Longitude, Altitude 193 headers.append("X-Smug-%s: %s" % (k, v)) 194 195 kwargs.update({"SessionID":self.sessionId, 196 "FileName":FileName, "ImageID":ImageID, "AlbumID":AlbumID}) 197 c = self._new_connection(url, kwargs) 198 c.setopt(c.UPLOAD, True) 199 c.setopt(c.HTTPHEADER, headers) 200 c.setopt(c.INFILESIZE, len(Data)) 201 c.setopt(c.READFUNCTION, image.read) 202 203 return self._perform(c)
204
205 -class SmugMug(SmugBase):
206 """Serial version of a SmugMug client.""" 207
208 - def _perform(self, c):
209 """Perform the low-level communication with SmugMug.""" 210 try: 211 c.perform() 212 return self._handle_response(c) 213 finally: 214 c.close()
215
216 - def _login(self, handler, **kwargs):
217 login = self._make_handler(handler) 218 session = login(SessionID=None, **kwargs) 219 self.sessionId = session['Login']['Session']['id'] 220 return self
221
222 - def login_anonymously(self, APIKey=None):
223 """Login into SmugMug anonymously. 224 225 @param APIKey: a SmugMug api key 226 @return: the SmugMug instance with a session established 227 """ 228 return self._login("login_anonymously", APIKey=APIKey)
229
230 - def login_withHash(self, UserID=None, PasswordHash=None, APIKey=None):
231 """Login into SmugMug with username, password and API key. 232 233 @param UserID: the account holder's user id 234 @param PasswordHash: the account holder's password hash 235 @param APIKey: a SmugMug api key 236 @return: the SmugMug instance with a session established 237 """ 238 return self._login("login_withHash", 239 UserID=UserID, PasswordHash=PasswordHash, APIKey=APIKey)
240
241 - def login_withPassword(self, EmailAddress=None, Password=None, APIKey=None):
242 """Login into SmugMug with username, password and API key. 243 244 @param EmailAddress: the account holder's email address 245 @param Password: the account holder's password 246 @param APIKey: a SmugMug api key 247 @return: the SmugMug instance with a session established 248 """ 249 return self._login("login_withPassword", 250 EmailAddress=EmailAddress, Password=Password, APIKey=APIKey)
251
252 - def categories_getTree(self):
253 """Return a tree of categories and sub-categories. 254 255 The format of the response tree:: 256 257 {'Category1': {'id': 41, 'SubCategories': {}}, 258 'Category2': {'id': 3, 259 'SubCategories': {'One': 4493, 260 'Two': 4299}}, 261 } 262 263 The primary purpose for this method is to provide an easy 264 mapping between name and id. 265 266 I{This method is not a standard smugmug method.} 267 """ 268 # @todo - how can this be integrated with SmugBatch? 269 methods = { 270 "smugmug.categories.get":"Categories", 271 "smugmug.subcategories.getAll":"SubCategories" 272 } 273 274 b = self.batch() 275 b.categories_get() 276 b.subcategories_getAll() 277 278 for params, results in b(): 279 method = results["method"] 280 methods[method] = results[methods[method]] 281 282 subtree = {} 283 for subcategory in methods["smugmug.subcategories.getAll"]: 284 category = subcategory["Category"]["id"] 285 subtree.setdefault(category, dict()) 286 subtree[category][subcategory["Name"]] = subcategory["id"] 287 288 tree = {} 289 for category in methods["smugmug.categories.get"]: 290 categoryId = category["id"] 291 tree[category["Name"]] = {"id":categoryId, "SubCategories":subtree.get(categoryId, {})} 292 293 return {"method":"pysmug.categories.getTree", "Categories":tree, "stat":"ok"}
294
295 -class SmugBatch(SmugBase):
296 """Batching version of a SmugMug client.""" 297
298 - def __init__(self, *args, **kwargs):
299 super(SmugBatch, self).__init__(*args, **kwargs) 300 self._batch = list() 301 """A list of requests pending executions.""" 302 self.concurrent = kwargs.get("concurrent", 10) 303 """The number of concurrent requests to execute."""
304
305 - def _perform(self, c):
306 """Store the request for later processing.""" 307 self._batch.append(c) 308 return None
309
310 - def __len__(self):
311 return len(self._batch)
312
313 - def __call__(self, n=None):
314 """Execute all pending requests. 315 316 @type n: int 317 @param n: maximum number of simultaneous connections 318 @return: a generator of results from the batch execution - order independent 319 """ 320 try: 321 return self._multi(self._batch[:], self._handle_response, n=n) 322 finally: 323 self._batch = list()
324
325 - def _handle_response(self, c):
326 """Catch any exceptions and return a valid response. 327 328 @type c: PycURL C{Curl} 329 @param c: a completed connection 330 """ 331 try: 332 return super(SmugBatch, self)._handle_response(c) 333 except Exception, e: 334 return {"exception":e, "stat":"fail", "code":-1}
335
336 - def _multi(self, batch, func, n=None):
337 """Perform the concurrent execution of all pending requests. 338 339 This method iterates over all the outstanding working at most 340 C{n} concurrently. On completion of each request the callback 341 function C{func} is invoked with the completed PycURL instance 342 from which the C{params} and C{response} can be extracted. 343 344 There is no distinction between a failure or success reponse, 345 both are C{yield}ed. 346 347 After receiving I{all} responses, the requests are closed. 348 349 @type batch: list<PycURL C{Curl}> 350 @param batch: a list of pending requests 351 @param func: callback function invoked on each completed request 352 @type n: int 353 @param n: the number of concurrent events to execute 354 """ 355 if not batch: 356 raise StopIteration() 357 358 n = (n if n is not None else self.concurrent) 359 if n <= 0: 360 raise SmugMugException("concurrent requests must be greater than zero") 361 362 ibatch = iter(batch) 363 total, working = len(batch), 0 364 365 m = pycurl.CurlMulti() 366 while total > 0: 367 for c in islice(ibatch, (n-working)): 368 m.add_handle(c) 369 working += 1 370 while True: 371 ret, nhandles = m.perform() 372 if ret != pycurl.E_CALL_MULTI_PERFORM: 373 break 374 while True: 375 q, ok, err = m.info_read() 376 for c in ok: 377 m.remove_handle(c) 378 yield (c.args, func(c)) 379 for c, errno, errmsg in err: 380 m.remove_handle(c) 381 yield (c.args, func(c)) 382 read = len(ok) + len(err) 383 total -= read 384 working -= read 385 if q == 0: 386 break 387 m.select(1.0) 388 389 while batch: 390 try: 391 batch.pop().close() 392 except: pass
393
394 - def images_download(self, AlbumID=None, Path=None, Format="Original"):
395 """Download the entire contents of an album to the specified path. 396 397 I{This method is not a standard smugmug method.} 398 399 @param AlbumID: the album to download 400 @param Path: the path to store the images 401 @param Format: the size of the image (check smugmug for possible sizes) 402 @return: a generator of responses containing the filenames saved locally 403 """ 404 path = os.path.abspath(os.getcwd() if not Path else Path) 405 406 self.images_get(AlbumID=AlbumID, Heavy=1) 407 album = list(self())[0][1] 408 409 path = os.path.join(path, str(AlbumID)) 410 if not os.path.exists(path): 411 os.mkdir(path) 412 413 fp = open(os.path.join(path, "album.txt"), "w") 414 pprint.pprint(album, fp) 415 fp.close() 416 417 connections = list() 418 for image in album["Images"]: 419 url = image.get(Format+"URL", None) 420 if url is None: 421 continue 422 fn = image.get("FileName", None) 423 if fn is None: 424 fn = os.path.split(url)[-1] 425 filename = os.path.join(path, fn) 426 connections.append(self._new_connection(url, {"FileName":filename})) 427 428 def f(c): 429 fn = c.args["FileName"] 430 fp = open(fn, "wb") 431 fp.write(c.response.getvalue()) 432 fp.close() 433 return fn
434 435 args = {"AlbumID":AlbumID, "Path":Path, "Format":Format} 436 for a in self._multi(connections, f): 437 r = {"method":"pysmug.images.download", "stat":"ok", "Image":{"FileName":a[1]}} 438 yield (args, r)
439