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
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
47 """Representation of a SmugMug exception."""
48
52
55 __repr__ = __str__
56
58 """An exception thrown during http(s) communication."""
59 pass
60
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
71 """Construct a dynamic handler for the SmugMug API."""
72
73 if method.startswith('__'):
74 raise AttributeError("no such attribute '%s'" % (method))
75 return self._make_handler(method)
76
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
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
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
120 c.setopt(c.SSL_VERIFYPEER, False)
121 return c
122
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
140
141
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
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
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
206 """Serial version of a SmugMug client."""
207
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
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
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
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
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
296 """Batching version of a SmugMug client."""
297
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
309
311 return len(self._batch)
312
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
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
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