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  from simplejson import loads as jsondecode 
 30   
 31  from pysmug import __version__ 
 32  from pysmug.methods import methods as _methods, apikeys as _apikeys 
 33   
 34  _userAgent = "pysmug(%s)" % (__version__) 
 35   
 36  _curlinfo = ( 
 37    ("total-time", pycurl.TOTAL_TIME), 
 38    ("upload-speed", pycurl.SPEED_UPLOAD), 
 39    ("download-speed", pycurl.SPEED_DOWNLOAD), 
 40  ) 
41 42 -class SmugMugException(Exception):
43 """Representation of a SmugMug exception.""" 44
45 - def __init__(self, message, code=0):
46 super(SmugMugException, self).__init__(message) 47 self.code = code
48
49 - def __str__(self):
50 return "%s (code=%d)" % (super(SmugMugException, self).__str__(), self.code)
51 __repr__ = __str__
52
53 -class HTTPException(SmugMugException):
54 """An exception thrown during http(s) communication.""" 55 pass
56
57 -class SmugBase(object):
58 """Abstract functionality for SmugMug API clients. 59 60 @type secure: bool 61 @ivar secure: whether to use a secure http connection 62 @ivar sessionId: session id from smugmug. 63 @ivar proxy: address of proxy server if one is required (http[s]://localhost[:8080]) 64 """ 65
66 - def __init__(self, sessionId=None, secure=True, proxy=None):
67 self.proxy = proxy 68 self.secure = secure 69 self.sessionId = sessionId
70
71 - def __getattr__(self, method):
72 """Construct a dynamic handler for the SmugMug API.""" 73 # Refuse to act as a proxy for unimplemented special methods 74 if method.startswith('__'): 75 raise AttributeError("no such attribute '%s'" % (method)) 76 return self._make_handler(method)
77 78 @property
79 - def protocol(self):
80 return self.secure and "https" or "http"
81
82 - def _make_handler(self, method):
83 method = "smugmug." + method.replace("_", ".") 84 85 if method not in _methods: 86 raise SmugMugException("no such smugmug method '%s'" % (method)) 87 88 def smugmug(*args, **kwargs): 89 """Dynamically created SmugMug function call.""" 90 if args: 91 raise SmugMugException("smugmug methods take no arguments, only named parameters") 92 kwargs = self._prepare_keywords(**kwargs) 93 defaults = {"method": method, "SessionID":self.sessionId} 94 for key, value in defaults.iteritems(): 95 if key not in kwargs: 96 kwargs[key] = value 97 elif kwargs[key] is None: 98 # remove a default by assigning None 99 del kwargs[key] 100 if "SessionID" in kwargs and kwargs["SessionID"] is None: 101 raise SmugMugException("not authenticated -- no valid session id") 102 query = urllib.urlencode(kwargs) 103 url = "%s://api.smugmug.com/services/api/json/1.2.1/?%s" % (self.protocol, query) 104 c = self._new_connection(url, kwargs) 105 return self._perform(c)
106 107 return smugmug
108
109 - def _new_connection(self, url, args):
110 """Prepare a new connection. 111 112 Create a new connection setting up the query string, 113 user agent header, response buffer and ssl parameters. 114 115 @param url: complete query string with parameters already encoded 116 @param args: arguments passed to method to be used for later callbacks 117 """ 118 c = pycurl.Curl() 119 c.args = args 120 c.setopt(c.URL, url) 121 logging.debug(url) 122 c.setopt(c.USERAGENT, _userAgent) 123 c.response = cStringIO.StringIO() 124 c.setopt(c.WRITEFUNCTION, c.response.write) 125 126 if self.proxy: 127 c.setopt(c.PROXY, self.proxy) 128 129 if self.secure: 130 c.setopt(c.SSL_VERIFYPEER, False) 131 132 return c
133
134 - def _handle_response(self, c):
135 """Handle the response from SmugMug. 136 137 This method decodes the JSON response and checks for any error 138 condition. It additionally adds a C{Statistics} item to the response 139 which contains upload & download times. 140 141 @type c: PycURL C{Curl} 142 @param c: a completed connection 143 @return: a dictionary of results corresponding to the SmugMug response 144 @raise SmugMugException: if an error exists in the response 145 """ 146 code = c.getinfo(c.HTTP_CODE) 147 if not code == 200: 148 raise HTTPException(c.errstr(), code) 149 150 json = c.response.getvalue() 151 152 logging.debug(json) 153 resp = jsondecode(json) 154 if not resp["stat"] == "ok": 155 raise SmugMugException(resp["message"], resp["code"]) 156 resp["Statistics"] = dict((key, c.getinfo(const)) for (key, const) in _curlinfo) 157 return resp
158
159 - def _perform(self, c):
160 """Execute the request. 161 162 A request pending execution. 163 164 @type c: PycURL C{Curl} 165 @param c: a pending request 166 """ 167 pass
168
169 - def _prepare_keywords(self, **kwargs):
170 """Prepare the keywords for sending to SmugMug. 171 172 The following steps are followed:: 173 1. If the key is C{method}, continue. 174 2. If the key starts with an upper case letter, continue. 175 3. If the key is in C{methods.apikeys}, replace the key. 176 4. If the key ends with C{id}, upper case the first letter 177 and C{ID} and replace the key. 178 5. Else, upper case the first letter only and replace the 179 key. 180 181 @param kwargs: the keywords to send to SmugMug 182 """ 183 items = kwargs.items() 184 for k, v, in items: 185 if k == "method": 186 continue 187 if k[0].isupper(): 188 continue 189 lk = k.lower() 190 if lk in _apikeys: 191 del kwargs[k] 192 kwargs[_apikeys[lk]] = v 193 else: 194 del kwargs[k] 195 if lk.endswith("id"): 196 kwargs[lk[:-2].title() + "ID"] = v 197 else: 198 kwargs[lk.title()] = v 199 return kwargs
200
201 - def batch(self):
202 """Return an instance of a batch-oriented SmugMug client.""" 203 return SmugBatch(self.sessionId, secure=self.secure, proxy=self.proxy)
204
205 - def images_upload(self, **kwargs):
206 """Upload the corresponding image. 207 208 B{One of ImageID or AlbumID must be present, but not both.} 209 210 @keyword data: the binary data of the image 211 @keyword imageId: the id of the image to replace 212 @keyword albumId: the name of the album in which to add the photo 213 @keyword filename: the name of the file 214 """ 215 kwargs = self._prepare_keywords(**kwargs) 216 AlbumID = kwargs.pop("AlbumID", None) 217 ImageID = kwargs.pop("ImageID", None) 218 Data = kwargs.pop("Data", None) 219 FileName = kwargs.pop("FileName", None) 220 221 if (ImageID is not None) and (AlbumID is not None): 222 raise SmugMugException("must set only one of AlbumID or ImageID") 223 224 if not Data: 225 if not (FileName or os.path.exists(FileName)): 226 raise SmugMugException("one of FileName or Data must be non-None") 227 Data = open(FileName, "rb").read() 228 229 filename = os.path.split(FileName)[-1] if FileName else "" 230 fingerprint = md5.new(Data).hexdigest() 231 image = cStringIO.StringIO(Data) 232 url = "%s://upload.smugmug.com/%s" % (self.protocol, filename) 233 234 headers = [ 235 "Host: upload.smugmug.com", 236 "Content-MD5: " + fingerprint, 237 "X-Smug-Version: 1.2.1", 238 "X-Smug-ResponseType: JSON", 239 "X-Smug-AlbumID: " + str(AlbumID) if AlbumID else "X-Smug-ImageID: " + str(ImageID), 240 "X-Smug-FileName: " + filename, 241 "X-Smug-SessionID: " + self.sessionId, 242 ] 243 for (k, v) in kwargs.items(): 244 # Caption, Keywords, Latitude, Longitude, Altitude 245 headers.append("X-Smug-%s: %s" % (k, v)) 246 247 kwargs.update({"SessionID":self.sessionId, 248 "FileName":FileName, "ImageID":ImageID, "AlbumID":AlbumID}) 249 c = self._new_connection(url, kwargs) 250 c.setopt(c.UPLOAD, True) 251 c.setopt(c.HTTPHEADER, [str(x) for x in headers]) 252 c.setopt(c.INFILESIZE, len(Data)) 253 c.setopt(c.READFUNCTION, image.read) 254 255 return self._perform(c)
256
257 -class SmugMug(SmugBase):
258 """Serial version of a SmugMug client.""" 259
260 - def _perform(self, c):
261 """Perform the low-level communication with SmugMug.""" 262 try: 263 c.perform() 264 return self._handle_response(c) 265 finally: 266 c.close()
267
268 - def _login(self, handler, keywords, **kwargs):
269 kwargs = self._prepare_keywords(**kwargs) 270 271 # check for the required keywords 272 #keys = set(kwargs.keys()) 273 274 login = self._make_handler(handler) 275 session = login(SessionID=None, **kwargs) 276 self.sessionId = session['Login']['Session']['id'] 277 return self
278
279 - def login_anonymously(self, **kwargs):
280 """Login into SmugMug anonymously using the API key. 281 282 @keyword APIKey: a SmugMug api key 283 @return: the SmugMug instance with a session established 284 """ 285 return self._login("login_anonymously", set(["APIKey"]), **kwargs)
286
287 - def login_withHash(self, **kwargs):
288 """Login into SmugMug with user id, password hash and API key. 289 290 @keyword userId: the account holder's user id 291 @keyword passwordHash: the account holder's password hash 292 @keyword APIKey: a SmugMug api key 293 @return: the SmugMug instance with a session established 294 """ 295 return self._login("login_withHash", 296 set(["UserID", "PasswordHash", "APIKey"]), **kwargs)
297
298 - def login_withPassword(self, **kwargs):
299 """Login into SmugMug with email address, password and API key. 300 301 @keyword emailAddress: the account holder's email address 302 @keyword password: the account holder's password 303 @keyword APIKey: a SmugMug api key 304 @return: the SmugMug instance with a session established 305 """ 306 return self._login("login_withPassword", 307 set(["EmailAddress", "Password", "APIKey"]), **kwargs)
308
309 - def categories_getTree(self):
310 """Return a tree of categories and sub-categories. 311 312 The format of the response tree:: 313 314 {'Category1': {'id': 41, 'SubCategories': {}}, 315 'Category2': {'id': 3, 316 'SubCategories': {'One': 4493, 317 'Two': 4299}}, 318 } 319 320 The primary purpose for this method is to provide an easy 321 mapping between name and id. 322 323 I{This method is not a standard smugmug method.} 324 325 @todo: how can this be integrated with SmugBatch? 326 """ 327 methods = { 328 "smugmug.categories.get":"Categories", 329 "smugmug.subcategories.getAll":"SubCategories" 330 } 331 332 b = self.batch() 333 b.categories_get() 334 b.subcategories_getAll() 335 336 for params, results in b(): 337 method = results["method"] 338 methods[method] = results[methods[method]] 339 340 subtree = {} 341 for subcategory in methods["smugmug.subcategories.getAll"]: 342 category = subcategory["Category"]["id"] 343 subtree.setdefault(category, dict()) 344 subtree[category][subcategory["Name"]] = subcategory["id"] 345 346 tree = {} 347 for category in methods["smugmug.categories.get"]: 348 categoryId = category["id"] 349 tree[category["Name"]] = {"id":categoryId, "SubCategories":subtree.get(categoryId, {})} 350 351 return {"method":"pysmug.categories.getTree", "Categories":tree, "stat":"ok"}
352
353 -class SmugBatch(SmugBase):
354 """Batching version of a SmugMug client. 355 356 @type _batch: list<PycURL C{Curl}> 357 @ivar _batch: list of requests pending executions 358 @type concurrent: int 359 @ivar concurrent: number of concurrent requests to execute 360 """ 361
362 - def __init__(self, *args, **kwargs):
363 super(SmugBatch, self).__init__(*args, **kwargs) 364 self._batch = list() 365 self.concurrent = kwargs.get("concurrent", 10)
366
367 - def _perform(self, c):
368 """Store the request for later processing.""" 369 self._batch.append(c) 370 return None
371
372 - def __len__(self):
373 return len(self._batch)
374
375 - def __call__(self, n=None):
376 """Execute all pending requests. 377 378 @type n: int 379 @param n: maximum number of simultaneous connections 380 @return: a generator of results from the batch execution - order independent 381 """ 382 try: 383 return self._multi(self._batch[:], self._handle_response, n=n) 384 finally: 385 self._batch = list()
386
387 - def _handle_response(self, c):
388 """Catch any exceptions and return a valid response. 389 390 @type c: PycURL C{Curl} 391 @param c: a completed connection 392 """ 393 try: 394 return super(SmugBatch, self)._handle_response(c) 395 except Exception, e: 396 return {"exception":e, "stat":"fail", "code":-1}
397
398 - def _multi(self, batch, func, n=None):
399 """Perform the concurrent execution of all pending requests. 400 401 This method iterates over all the outstanding working at most 402 C{n} concurrently. On completion of each request the callback 403 function C{func} is invoked with the completed PycURL instance 404 from which the C{params} and C{response} can be extracted. 405 406 There is no distinction between a failure or success reponse, 407 both are C{yield}ed. 408 409 After receiving I{all} responses, the requests are closed. 410 411 @type batch: list<PycURL C{Curl}> 412 @param batch: a list of pending requests 413 @param func: callback function invoked on each completed request 414 @type n: int 415 @param n: the number of concurrent events to execute 416 """ 417 if not batch: 418 raise StopIteration() 419 420 n = (n if n is not None else self.concurrent) 421 if n <= 0: 422 raise SmugMugException("concurrent requests must be greater than zero") 423 424 ibatch = iter(batch) 425 total, working = len(batch), 0 426 427 m = pycurl.CurlMulti() 428 while total > 0: 429 for c in islice(ibatch, (n-working)): 430 m.add_handle(c) 431 working += 1 432 while True: 433 ret, nhandles = m.perform() 434 if ret != pycurl.E_CALL_MULTI_PERFORM: 435 break 436 while True: 437 q, ok, err = m.info_read() 438 for c in ok: 439 m.remove_handle(c) 440 yield (c.args, func(c)) 441 for c, errno, errmsg in err: 442 m.remove_handle(c) 443 yield (c.args, func(c)) 444 read = len(ok) + len(err) 445 total -= read 446 working -= read 447 if q == 0: 448 break 449 m.select(1.0) 450 451 while batch: 452 try: 453 batch.pop().close() 454 except: pass
455
456 - def images_download(self, **kwargs):
457 """Download the entire contents of an album to the specified path. 458 459 I{This method is not a standard smugmug method.} 460 461 @keyword albumId: the album to download 462 @keyword path: the path to store the images 463 @keyword format: the size of the image (check smugmug for possible sizes) 464 @return: a generator of responses containing the filenames saved locally 465 """ 466 kwargs = self._prepare_keywords(**kwargs) 467 AlbumID = kwargs.get("AlbumID", None) 468 Path = kwargs.get("Path", None) 469 Format = kwargs.get("Format", "Original") 470 471 path = os.path.abspath(os.getcwd() if not Path else Path) 472 473 self.images_get(AlbumID=AlbumID, Heavy=1) 474 album = list(self())[0][1] 475 476 path = os.path.join(path, str(AlbumID)) 477 if not os.path.exists(path): 478 os.mkdir(path) 479 480 fp = open(os.path.join(path, "album.txt"), "w") 481 pprint.pprint(album, fp) 482 fp.close() 483 484 connections = list() 485 for image in album["Images"]: 486 url = image.get(Format+"URL", None) 487 if url is None: 488 continue 489 fn = image.get("FileName", None) 490 if fn is None: 491 fn = os.path.split(url)[-1] 492 filename = os.path.join(path, fn) 493 connections.append(self._new_connection(url, {"FileName":filename})) 494 495 def f(c): 496 fn = c.args["FileName"] 497 fp = open(fn, "wb") 498 fp.write(c.response.getvalue()) 499 fp.close() 500 return fn
501 502 args = {"AlbumID":AlbumID, "Path":Path, "Format":Format} 503 for a in self._multi(connections, f): 504 r = {"method":"pysmug.images.download", "stat":"ok", "Image":{"FileName":a[1]}} 505 yield (args, r)
506