diff --git a/README.markdown b/README.markdown
index ac42b5f..e9bbd38 100644
--- a/README.markdown
+++ b/README.markdown
@@ -5,7 +5,7 @@ Open Photo API / Python Library
----------------------------------------
### Installation
-python setup.py install
+ python setup.py install
----------------------------------------
@@ -13,11 +13,27 @@ python setup.py install
### How to use the library
To use the library you need to first ``import openphoto``, then instantiate an instance of the class and start making calls.
-
+
+You can use the library in one of two ways:
+ * Direct GET/POST calls to the server
+ * Access via Python classes/methods
+
+
+### Direct GET/POST:
+
from openphoto import OpenPhoto
client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret)
- resp = client.get('/photos/list.json')
- resp = client.post('/photo/62/update.json', {'tags': 'tag1,tag2'})
+ resp = client.get("/photos/list.json")
+ resp = client.post("/photo/62/update.json", tags=["tag1", "tag2"])
+
+
+### Python classes/methods
+
+ from openphoto import OpenPhoto
+ client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret)
+ photos = client.photos_list()
+ photos[0].update(tags=["tag1", "tag2"])
+ print photos[0].tags
----------------------------------------
diff --git a/openphoto/__init__.py b/openphoto/__init__.py
index c190760..3481837 100644
--- a/openphoto/__init__.py
+++ b/openphoto/__init__.py
@@ -1,50 +1,8 @@
-import oauth2 as oauth
-import urlparse
-import urllib
-import httplib2
-import types
+from openphoto_http import OpenPhotoHttp, OpenPhotoError, OpenPhotoDuplicateError
+from api_photo import ApiPhoto
+from api_tag import ApiTag
+from api_album import ApiAlbum
-
-class OpenPhoto(object):
+class OpenPhoto(OpenPhotoHttp, ApiPhoto, ApiTag, ApiAlbum):
""" Client library for OpenPhoto """
-
- def __init__(self, host, consumer_key='', consumer_secret='',
- token='', token_secret=''):
- self.host = host
- self.consumer_key = consumer_key
- self.consumer_secret = consumer_secret
- self.token = token
- self.token_secret = token_secret
-
- def get(self, endpoint, params={}):
- url = urlparse.urlunparse(('http', self.host, endpoint, '',
- urllib.urlencode(params), ''))
- if self.consumer_key:
- consumer = oauth.Consumer(self.consumer_key, self.consumer_secret)
- token = oauth.Token(self.token, self.token_secret)
-
- client = oauth.Client(consumer, token)
-
- else:
- client = httplib2.Http()
-
- headers, content = client.request(url, "GET")
- return content
-
- def post(self, endpoint, params={}):
- url = urlparse.urlunparse(('http', self.host, endpoint, '', '', ''))
-
- if self.consumer_key:
- consumer = oauth.Consumer(self.consumer_key, self.consumer_secret)
- token = oauth.Token(self.token, self.token_secret)
-
- # ensure utf-8 encoding for all values.
- params = dict([(k, v.encode('utf-8')
- if type(v) is types.UnicodeType else v)
- for (k, v) in params.items()])
-
- client = oauth.Client(consumer, token)
- body = urllib.urlencode(params)
- headers, content = client.request(url, "POST", body)
-
- return content
+ pass
diff --git a/openphoto/api_album.py b/openphoto/api_album.py
new file mode 100644
index 0000000..e2ad812
--- /dev/null
+++ b/openphoto/api_album.py
@@ -0,0 +1,33 @@
+from openphoto_http import OpenPhotoHttp, OpenPhotoError
+from objects import Album
+
+class ApiAlbum(OpenPhotoHttp):
+ def album_create(self, name, **kwds):
+ """ Create a new album and return it"""
+ result = self.post("/album/create.json", name=name, **kwds)["result"]
+ return Album(self, result)
+
+ def album_delete(self, album_id, **kwds):
+ """ Delete an album """
+ album = Album(self, {"id": album_id})
+ album.delete(**kwds)
+
+ def album_form(self, album_id, **kwds):
+ raise NotImplementedError()
+
+ def album_add_photos(self, album_id, photo_ids, **kwds):
+ raise NotImplementedError()
+
+ def album_remove_photos(self, album_id, photo_ids, **kwds):
+ raise NotImplementedError()
+
+ def albums_list(self, **kwds):
+ """ Return a list of Album objects """
+ results = self.get("/albums/list.json", **kwds)["result"]
+ return [Album(self, album) for album in results]
+
+ def album_update(self, album_id, **kwds):
+ """ Update an album """
+ album = Album(self, {"id": album_id})
+ album.update(**kwds)
+ # Don't return the album, since the API doesn't give us the modified album
diff --git a/openphoto/api_photo.py b/openphoto/api_photo.py
new file mode 100644
index 0000000..03d1145
--- /dev/null
+++ b/openphoto/api_photo.py
@@ -0,0 +1,81 @@
+import base64
+
+from openphoto_http import OpenPhotoHttp, OpenPhotoError
+from objects import Photo
+
+class ApiPhoto(OpenPhotoHttp):
+ def photo_delete(self, photo_id, **kwds):
+ """ Delete a photo """
+ photo = Photo(self, {"id": photo_id})
+ photo.delete(**kwds)
+
+ def photo_edit(self, photo_id, **kwds):
+ """ Returns an HTML form to edit a photo """
+ photo = Photo(self, {"id": photo_id})
+ return photo.edit(**kwds)
+
+ def photo_replace(self, photo_id, photo_file, **kwds):
+ raise NotImplementedError()
+
+ def photo_replace_encoded(self, photo_id, photo_file, **kwds):
+ raise NotImplementedError()
+
+ def photo_update(self, photo_id, **kwds):
+ """
+ Update a photo with the specified parameters.
+ Returns the updated photo object
+ """
+ photo = Photo(self, {"id": photo_id})
+ photo.update(**kwds)
+ return photo
+
+ def photo_view(self, photo_id, **kwds):
+ """
+ Used to view the photo at a particular size.
+ Returns the requested photo object
+ """
+ photo = Photo(self, {"id": photo_id})
+ photo.view(**kwds)
+ return photo
+
+ def photos_list(self, **kwds):
+ """ Returns a list of Photo objects """
+ photos = self.get("/photos/list.json", **kwds)["result"]
+ photos = self._result_to_list(photos)
+ return [Photo(self, photo) for photo in photos]
+
+ def photos_update(self, photo_ids, **kwds):
+ """ Updates a list of photos """
+ if not self._openphoto.post("/photos/update.json" % photo_ids,
+ **kwds)["result"]:
+ raise OpenPhotoError("Update response returned False")
+
+ def photos_delete(self, photo_ids, **kwds):
+ """ Deletes a list of photos """
+ if not self._openphoto.post("/photos/delete.json" % photo_ids,
+ **kwds)["result"]:
+ raise OpenPhotoError("Delete response returned False")
+
+ def photo_upload(self, photo_file, **kwds):
+ raise NotImplementedError("Use photo_upload_encoded instead.")
+
+ def photo_upload_encoded(self, photo_file, **kwds):
+ """ Base64-encodes and uploads the specified file """
+ encoded_photo = base64.b64encode(open(photo_file, "rb").read())
+ result = self.post("/photo/upload.json", photo=encoded_photo,
+ **kwds)["result"]
+ return Photo(self, result)
+
+ def photo_dynamic_url(self, photo_id, **kwds):
+ raise NotImplementedError()
+
+ def photo_next_previous(self, photo_id, **kwds):
+ """
+ Returns a dict containing the next and previous photo objects,
+ given a photo in the middle.
+ """
+ photo = Photo(self, {"id": photo_id})
+ return photo.next_previous(**kwds)
+
+ def photo_transform(self, photo_id, **kwds):
+ raise NotImplementedError()
diff --git a/openphoto/api_tag.py b/openphoto/api_tag.py
new file mode 100644
index 0000000..c7c4a7f
--- /dev/null
+++ b/openphoto/api_tag.py
@@ -0,0 +1,25 @@
+from openphoto_http import OpenPhotoHttp, OpenPhotoError
+from objects import Tag
+
+class ApiTag(OpenPhotoHttp):
+ def tag_create(self, tag_id, **kwds):
+ """ Create a new tag and return it """
+ result = self.post("/tag/create.json", tag=tag_id, **kwds)["result"]
+ return Tag(self, result)
+
+ def tag_delete(self, tag_id, **kwds):
+ """ Delete a tag """
+ tag = Tag(self, {"id": tag_id})
+ tag.delete(**kwds)
+
+ def tag_update(self, tag_id, **kwds):
+ """ Update a tag """
+ tag = Tag(self, {"id": tag_id})
+ tag.update(**kwds)
+ return tag
+
+ def tags_list(self, **kwds):
+ """ Returns a list of Tag objects """
+ results = self.get("/tags/list.json", **kwds)["result"]
+ return [Tag(self, tag) for tag in results]
+
diff --git a/openphoto/main.py b/openphoto/main.py
index f8df7ad..f3c64ae 100644
--- a/openphoto/main.py
+++ b/openphoto/main.py
@@ -6,9 +6,9 @@ import urllib
from optparse import OptionParser
try:
- import simplejson as json
-except:
import json
+except ImportError:
+ import simplejson as json
from openphoto import OpenPhoto
@@ -45,16 +45,16 @@ def main(args=sys.argv[1:]):
client = OpenPhoto(options.host, consumer_key, consumer_secret, token, token_secret)
if options.method == "GET":
- result = client.get(options.endpoint, params)
+ result = client.get_raw(options.endpoint, **params)
else:
- result = client.post(options.endpoint, params)
+ result = client.post_raw(options.endpoint, **params)
if options.verbose:
print "==========\nMethod: %s\nHost: %s\nEndpoint: %s" % (options.method, options.host, options.endpoint)
if len( params ) > 0:
- print "Fields:"
- for kv in params.iteritems():
- print " %s=%s" % kv
+ print "Fields:"
+ for kv in params.iteritems():
+ print " %s=%s" % kv
print "==========\n"
if options.pretty:
diff --git a/openphoto/objects.py b/openphoto/objects.py
new file mode 100644
index 0000000..2af570f
--- /dev/null
+++ b/openphoto/objects.py
@@ -0,0 +1,147 @@
+from openphoto_http import OpenPhotoError, NotImplementedError
+
+class OpenPhotoObject:
+ """ Base object supporting the storage of custom fields as attributes """
+ def __init__(self, openphoto, json_dict):
+ self._openphoto = openphoto
+ self._json_dict = json_dict
+ self._set_fields(json_dict)
+
+ def _set_fields(self, json_dict):
+ """ Set this object's attributes specified in json_dict """
+ for key, value in json_dict.items():
+ if key.startswith("_"):
+ raise ValueError("Illegal attribute: %s" % key)
+ setattr(self, key, value)
+
+ def _replace_fields(self, json_dict):
+ """
+ Delete this object's attributes, and replace with
+ those in json_dict.
+ """
+ for key in self._json_dict.keys():
+ delattr(self, key)
+ self._json_dict = json_dict
+ self._set_fields(json_dict)
+
+ def __repr__(self):
+ if hasattr(self, "name"):
+ return "<%s name='%s'>" % (self.__class__, self.name)
+ elif hasattr(self, "id"):
+ return "<%s id='%s'>" % (self.__class__, self.id)
+ else:
+ return "<%s>" % (self.__class__)
+
+ def get_fields(self):
+ """ Returns this object's attributes """
+ return self._json_dict
+
+
+class Photo(OpenPhotoObject):
+ def delete(self, **kwds):
+ """ Delete this photo """
+ self._openphoto.post("/photo/%s/delete.json" % self.id, **kwds)
+ self._replace_fields({})
+
+ def edit(self, **kwds):
+ """ Returns an HTML form to edit the photo """
+ result = self._openphoto.get("/photo/%s/edit.json" % self.id,
+ **kwds)["result"]
+ return result["markup"]
+
+ def replace(self, photo_file, **kwds):
+ raise NotImplementedError()
+
+ def replace_encoded(self, encoded_photo, **kwds):
+ raise NotImplementedError()
+
+ def update(self, **kwds):
+ """ Update this photo with the specified parameters """
+ new_dict = self._openphoto.post("/photo/%s/update.json" % self.id,
+ **kwds)["result"]
+ self._replace_fields(new_dict)
+
+ def view(self, **kwds):
+ """
+ Used to view the photo at a particular size.
+ Updates the photo's fields with the response.
+ """
+ new_dict = self._openphoto.get("/photo/%s/view.json" % self.id,
+ **kwds)["result"]
+ self._replace_fields(new_dict)
+
+ def dynamic_url(self, **kwds):
+ raise NotImplementedError()
+
+ def next_previous(self, **kwds):
+ """ Returns a dict containing the next and previous photo objects """
+ result = self._openphoto.get("/photo/%s/nextprevious.json" % self.id,
+ **kwds)["result"]
+ value = {}
+ if "next" in result:
+ value["next"] = Photo(self._openphoto, result["next"])
+ if "previous" in result:
+ value["previous"] = Photo(self._openphoto, result["previous"])
+ return value
+
+ def transform(self, **kwds):
+ raise NotImplementedError()
+
+
+class Tag(OpenPhotoObject):
+ def delete(self, **kwds):
+ """ Delete this tag """
+ self._openphoto.post("/tag/%s/delete.json" % self.id, **kwds)
+ self._replace_fields({})
+
+ def update(self, **kwds):
+ """ Update this tag with the specified parameters """
+ new_dict = self._openphoto.post("/tag/%s/update.json" % self.id,
+ **kwds)["result"]
+ self._replace_fields(new_dict)
+
+
+class Album(OpenPhotoObject):
+ def __init__(self, openphoto, json_dict):
+ OpenPhotoObject.__init__(self, openphoto, json_dict)
+ # Update the cover attribute with a photo object
+ if hasattr(self, "cover") and self.cover is not None:
+ self.cover = Photo(openphoto, self.cover)
+
+ def delete(self, **kwds):
+ """ Delete this album """
+ self._openphoto.post("/album/%s/delete.json" % self.id, **kwds)
+ self._replace_fields({})
+
+ def form(self, **kwds):
+ raise NotImplementedError()
+
+ def add_photos(self, **kwds):
+ raise NotImplementedError()
+
+ def remove_photos(self, **kwds):
+ raise NotImplementedError()
+
+ def update(self, **kwds):
+ """ Update this album with the specified parameters """
+ self._openphoto.post("/album/%s/update.json" % self.id,
+ **kwds)["result"]
+ # Since the API doesn't give us the modified album, we need to
+ # update our fields based on the kwds that were sent
+ self._set_fields(kwds)
+
+
+ def view(self, **kwds):
+ """
+ Requests the full contents of the album.
+ Updates the album's fields with the response.
+ """
+ result = self._openphoto.get("/album/%s/view.json" % self.id,
+ **kwds)["result"]
+ # Update the cover attribute with a photo object
+ if result["cover"] is not None:
+ result["cover"] = Photo(self._openphoto, result["cover"])
+ # Update the photo list with photo objects
+ for i, photo in enumerate(result["photos"]):
+ result["photos"][i] = Photo(self._openphoto, result["photos"][i])
+ self._replace_fields(result)
diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py
new file mode 100644
index 0000000..d06085d
--- /dev/null
+++ b/openphoto/openphoto_http.py
@@ -0,0 +1,135 @@
+import oauth2 as oauth
+import urlparse
+import urllib
+import httplib2
+try:
+ import json
+except ImportError:
+ import simplejson as json
+
+class OpenPhotoError(Exception):
+ """ Indicates that an OpenPhoto operation failed """
+ pass
+
+class OpenPhotoDuplicateError(OpenPhotoError):
+ """ Indicates that an upload operation failed due to a duplicate photo """
+ pass
+
+class NotImplementedError(OpenPhotoError):
+ """ Indicates that the API function has not yet been coded - please help! """
+ pass
+
+DUPLICATE_RESPONSE = {"code": 409,
+ "message": "This photo already exists"}
+
+class OpenPhotoHttp:
+ """ Base class to handle HTTP requests to an OpenPhoto server """
+ def __init__(self, host, consumer_key='', consumer_secret='',
+ token='', token_secret=''):
+ self._host = host
+ self._consumer_key = consumer_key
+ self._consumer_secret = consumer_secret
+ self._token = token
+ self._token_secret = token_secret
+
+ def get(self, endpoint, **params):
+ """
+ Performs an HTTP GET from the specified endpoint (API path),
+ passing parameters if given.
+ Returns the decoded JSON dictionary, and
+ raises exceptions if an error code is received.
+ """
+ response = json.loads(self.get_raw(endpoint, **params))
+ self._process_response(response)
+ return response
+
+ def post(self, endpoint, **params):
+ """
+ Performs an HTTP POST to the specified endpoint (API path),
+ passing parameters if given.
+ Returns the decoded JSON dictionary, and
+ raises exceptions if an error code is received.
+ """
+ response = json.loads(self.post_raw(endpoint, **params))
+ self._process_response(response)
+ return response
+
+ def get_raw(self, endpoint, **params):
+ """
+ Performs an HTTP GET from the specified endpoint (API path),
+ passing parameters if given.
+ Returns the raw HTTP content string.
+ """
+ params = self._process_params(params)
+ url = urlparse.urlunparse(('http', self._host, endpoint, '',
+ urllib.urlencode(params), ''))
+ if self._consumer_key:
+ consumer = oauth.Consumer(self._consumer_key, self._consumer_secret)
+ token = oauth.Token(self._token, self._token_secret)
+ client = oauth.Client(consumer, token)
+ else:
+ client = httplib2.Http()
+
+ _, content = client.request(url, "GET")
+ return content
+
+ def post_raw(self, endpoint, **params):
+ """
+ Performs an HTTP POST to the specified endpoint (API path),
+ passing parameters if given.
+ Returns the raw HTTP content string.
+ """
+ params = self._process_params(params)
+ url = urlparse.urlunparse(('http', self._host, endpoint, '', '', ''))
+
+ if not self._consumer_key:
+ raise OpenPhotoError("Cannot issue POST without OAuth tokens")
+
+ consumer = oauth.Consumer(self._consumer_key, self._consumer_secret)
+ token = oauth.Token(self._token, self._token_secret)
+
+ client = oauth.Client(consumer, token)
+ body = urllib.urlencode(params)
+ _, content = client.request(url, "POST", body)
+ return content
+
+ @staticmethod
+ def _process_params(params):
+ """ Converts Unicode/lists/booleans inside HTTP parameters """
+ processed_params = {}
+ for key, value in params.items():
+ # Use UTF-8 encoding
+ if isinstance(value, unicode):
+ value = value.encode('utf-8')
+ # Handle lists
+ if isinstance(value, list):
+ value = ",".join(value)
+ # Handle booleans
+ if isinstance(value, bool):
+ value = 1 if value else 0
+ processed_params[key] = value
+ return processed_params
+
+ @staticmethod
+ def _process_response(response):
+ """ Raises an exception if an invalid response code is received """
+ if response["code"] >= 200 and response["code"] < 300:
+ return
+
+ error_message = "Code %d: %s" % (response["code"],
+ response["message"])
+
+ # Special case for a duplicate photo error
+ if (response["code"] == DUPLICATE_RESPONSE["code"] and
+ DUPLICATE_RESPONSE["message"] in response["message"]):
+ raise OpenPhotoDuplicateError(error_message)
+
+ raise OpenPhotoError(error_message)
+
+ @staticmethod
+ def _result_to_list(result):
+ """ Handle the case where the result contains no items """
+ if result[0]["totalRows"] == 0:
+ return []
+ else:
+ return result
diff --git a/setup.py b/setup.py
old mode 100644
new mode 100755
index 88b5589..9dd0687
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env python
+
requires = ['oauth2', 'httplib2']
try:
import json
@@ -17,7 +19,7 @@ except ImportError:
'requires': requires}
setup(name='openphoto',
- version='0.1',
+ version='0.2',
description='Client library for the openphoto project',
author='James Walker',
author_email='walkah@walkah.net',