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 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 )
45 """Representation of a SmugMug exception."""
46
50
53 __repr__ = __str__
54
56 """An exception thrown during http(s) communication."""
57 pass
58
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
86 """Construct a dynamic handler for the SmugMug API."""
87
88 if method.startswith('__'):
89 raise AttributeError("no such attribute '%s'" % (method))
90 return self._make_handler(method)
91
92 @property
94 return self.secure and "https" or "http"
95
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
113 del kwargs[key]
114 if "SessionID" in kwargs and kwargs["SessionID"] is None:
115 raise SmugMugException("not authenticated -- no valid session id")
116
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
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
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
194
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
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
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
253 """Serial version of a SmugMug client."""
254
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
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
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
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
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
310 concurrent = kwargs.pop("concurrent", _concurrent)
311 super(SmugBatch, self).__init__(*args, **kwargs)
312 self._batch = list()
313 self.concurrent = concurrent
314
319
321 return len(self._batch)
322
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
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
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