1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 )
43 """Representation of a SmugMug exception."""
44
48
51 __repr__ = __str__
52
54 """An exception thrown during http(s) communication."""
55 pass
56
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
72 """Construct a dynamic handler for the SmugMug API."""
73
74 if method.startswith('__'):
75 raise AttributeError("no such attribute '%s'" % (method))
76 return self._make_handler(method)
77
78 @property
80 return self.secure and "https" or "http"
81
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
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
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
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
168
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
202 """Return an instance of a batch-oriented SmugMug client."""
203 return SmugBatch(self.sessionId, secure=self.secure, proxy=self.proxy)
204
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
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
258 """Serial version of a SmugMug client."""
259
267
268 - def _login(self, handler, keywords, **kwargs):
269 kwargs = self._prepare_keywords(**kwargs)
270
271
272
273
274 login = self._make_handler(handler)
275 session = login(SessionID=None, **kwargs)
276 self.sessionId = session['Login']['Session']['id']
277 return self
278
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
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
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
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
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
363 super(SmugBatch, self).__init__(*args, **kwargs)
364 self._batch = list()
365 self.concurrent = kwargs.get("concurrent", 10)
366
371
373 return len(self._batch)
374
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
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
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