pysmug.smugmug

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