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