diff --git a/.gitignore b/.gitignore index 8f8a3ff..54bad91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ +*~ *.pyc build dist *.egg-info - +tests/tokens.py +tests.log diff --git a/README.markdown b/README.markdown index a0821c6..2e29314 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,18 +13,40 @@ 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 + +The OpenPhoto Python class hierarchy mirrors the [OpenPhoto API](http://theopenphotoproject.org/documentation) endpoint layout. For example, the calls in the example above use the following API endpoints: + +* client.photos.list() -> /photos/list.json +* photos[0].update() -> /photo/<id>/update.json ---------------------------------------- ### Using from the command line -You'll then want to export your authentication credentials to the environment. +When using the command line tool, you'll want to export your authentication credentials to the environment. The command line tool will look for the following config file in ~/.config/openphoto/config (the -c switch lets you specify a different config file location): @@ -97,3 +119,4 @@ Now you can run commands to the OpenPhoto API from your shell! You can get your credentals by clicking on the arrow next to your email address once you're logged into your site and then clicking on settings. If you don't have any credentials then you can create one for yourself by going to `/v1/oauth/flow`. Once completed go back to the settings page and you should see the credential you just created + diff --git a/openphoto/__init__.py b/openphoto/__init__.py index c190760..7e497bd 100644 --- a/openphoto/__init__.py +++ b/openphoto/__init__.py @@ -1,50 +1,21 @@ -import oauth2 as oauth -import urlparse -import urllib -import httplib2 -import types +from openphoto_http import OpenPhotoHttp +from errors import * +import api_photo +import api_tag +import api_album - -class OpenPhoto(object): +class OpenPhoto(OpenPhotoHttp): """ Client library for OpenPhoto """ - - def __init__(self, host, consumer_key='', consumer_secret='', + 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 + OpenPhotoHttp.__init__(self, host, + consumer_key, consumer_secret, + token, token_secret) + + self.photos = api_photo.ApiPhotos(self) + self.photo = api_photo.ApiPhoto(self) + self.tags = api_tag.ApiTags(self) + self.tag = api_tag.ApiTag(self) + self.albums = api_album.ApiAlbums(self) + self.album = api_album.ApiAlbum(self) diff --git a/openphoto/api_album.py b/openphoto/api_album.py new file mode 100644 index 0000000..e997b65 --- /dev/null +++ b/openphoto/api_album.py @@ -0,0 +1,55 @@ +from errors import * +from objects import Album + +class ApiAlbums: + def __init__(self, client): + self._client = client + + def list(self, **kwds): + """ Return a list of Album objects """ + results = self._client.get("/albums/list.json", **kwds)["result"] + return [Album(self._client, album) for album in results] + +class ApiAlbum: + def __init__(self, client): + self._client = client + + def create(self, name, **kwds): + """ Create a new album and return it""" + result = self._client.post("/album/create.json", name=name, **kwds)["result"] + return Album(self._client, result) + + def delete(self, album, **kwds): + """ Delete an album """ + if not isinstance(album, Album): + album = Album(self._client, {"id": album}) + album.delete(**kwds) + + def form(self, album, **kwds): + raise NotImplementedError() + + def add_photos(self, album, photos, **kwds): + raise NotImplementedError() + + def remove_photos(self, album, photos, **kwds): + raise NotImplementedError() + + def update(self, album, **kwds): + """ Update an album """ + if not isinstance(album, Album): + album = Album(self._client, {"id": album}) + album.update(**kwds) + + # Don't return the album, since the API currently doesn't give us the modified album + # TODO: Uncomment the following once frontend issue #937 is resolved +# return album + + def view(self, album, **kwds): + """ + View an album's contents. + Returns the requested album object. + """ + if not isinstance(album, Album): + album = Album(self._client, {"id": album}) + album.view(**kwds) + return album diff --git a/openphoto/api_photo.py b/openphoto/api_photo.py new file mode 100644 index 0000000..fed9c8a --- /dev/null +++ b/openphoto/api_photo.py @@ -0,0 +1,94 @@ +import base64 + +from errors import * +from objects import Photo + +class ApiPhotos: + def __init__(self, client): + self._client = client + + def list(self, **kwds): + """ Returns a list of Photo objects """ + photos = self._client.get("/photos/list.json", **kwds)["result"] + photos = self._client._result_to_list(photos) + return [Photo(self._client, photo) for photo in photos] + + def update(self, photos, **kwds): + """ Updates a list of photos """ + if not self._client.post("/photos/update.json", ids=photos, **kwds)["result"]: + raise OpenPhotoError("Update response returned False") + + def delete(self, photos, **kwds): + """ Deletes a list of photos """ + if not self._client.post("/photos/delete.json", ids=photos, **kwds)["result"]: + raise OpenPhotoError("Delete response returned False") + + +class ApiPhoto: + def __init__(self, client): + self._client = client + + def delete(self, photo, **kwds): + """ Delete a photo """ + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) + photo.delete(**kwds) + + def edit(self, photo, **kwds): + """ Returns an HTML form to edit a photo """ + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) + return photo.edit(**kwds) + + def replace(self, photo, photo_file, **kwds): + raise NotImplementedError() + + def replace_encoded(self, photo, photo_file, **kwds): + raise NotImplementedError() + + def update(self, photo, **kwds): + """ + Update a photo with the specified parameters. + Returns the updated photo object + """ + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) + photo.update(**kwds) + return photo + + def view(self, photo, **kwds): + """ + Used to view the photo at a particular size. + Returns the requested photo object + """ + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) + photo.view(**kwds) + return photo + + def upload(self, photo_file, **kwds): + result = self._client.post("/photo/upload.json", files={'photo': photo_file}, + **kwds)["result"] + return Photo(self._client, result) + + def upload_encoded(self, photo_file, **kwds): + """ Base64-encodes and uploads the specified file """ + encoded_photo = base64.b64encode(open(photo_file, "rb").read()) + result = self._client.post("/photo/upload.json", photo=encoded_photo, + **kwds)["result"] + return Photo(self._client, result) + + def dynamic_url(self, photo, **kwds): + raise NotImplementedError() + + def next_previous(self, photo, **kwds): + """ + Returns a dict containing the next and previous photo lists + (there may be more than one next/previous photo returned). + """ + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) + return photo.next_previous(**kwds) + + def transform(self, photo, **kwds): + raise NotImplementedError() diff --git a/openphoto/api_tag.py b/openphoto/api_tag.py new file mode 100644 index 0000000..89f9fee --- /dev/null +++ b/openphoto/api_tag.py @@ -0,0 +1,33 @@ +from errors import * +from objects import Tag + +class ApiTags: + def __init__(self, client): + self._client = client + + def list(self, **kwds): + """ Returns a list of Tag objects """ + results = self._client.get("/tags/list.json", **kwds)["result"] + return [Tag(self._client, tag) for tag in results] + +class ApiTag: + def __init__(self, client): + self._client = client + + def create(self, tag, **kwds): + """ Create a new tag and return it """ + result = self._client.post("/tag/create.json", tag=tag, **kwds)["result"] + return Tag(self._client, result) + + def delete(self, tag, **kwds): + """ Delete a tag """ + if not isinstance(tag, Tag): + tag = Tag(self._client, {"id": tag}) + tag.delete(**kwds) + + def update(self, tag, **kwds): + """ Update a tag """ + if not isinstance(tag, Tag): + tag = Tag(self._client, {"id": tag}) + tag.update(**kwds) + return tag diff --git a/openphoto/errors.py b/openphoto/errors.py new file mode 100644 index 0000000..25a0b24 --- /dev/null +++ b/openphoto/errors.py @@ -0,0 +1,12 @@ +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 + diff --git a/openphoto/main.py b/openphoto/main.py index 1a6a6f6..be13214 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 @@ -85,16 +85,17 @@ def main(args=sys.argv[1:]): config['token'], config['tokenSecret']) if options.method == "GET": - result = client.get(options.endpoint, params) + result = client.get(options.endpoint, process_response=False, **params) else: - result = client.post(options.endpoint, params) + params, files = extract_files(params) + result = client.post(options.endpoint, process_response=False, files=files, **params) if options.verbose: print "==========\nMethod: %s\nHost: %s\nEndpoint: %s" % (options.method, config['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: @@ -102,5 +103,24 @@ def main(args=sys.argv[1:]): else: print result +def extract_files(params): + """ + Extract filenames from the "photo" parameter, so they can be uploaded, returning (updated_params, files). + Uses the same technique as openphoto-php: + * Filename can only be in the "photo" parameter + * Filename must be prefixed with "@" + * Filename must exist + ...otherwise the parameter is not extracted + """ + files = {} + updated_params = {} + for name in params: + if name == "photo" and params[name].startswith("@") and os.path.isfile(os.path.expanduser(params[name][1:])): + files[name] = params[name][1:] + else: + updated_params[name] = params[name] + + return updated_params, files + if __name__ == "__main__": main() diff --git a/openphoto/multipart_post.py b/openphoto/multipart_post.py new file mode 100644 index 0000000..e53fb38 --- /dev/null +++ b/openphoto/multipart_post.py @@ -0,0 +1,31 @@ +import os +import mimetypes +import mimetools + +def encode_multipart_formdata(params, files): + boundary = mimetools.choose_boundary() + + lines = [] + for name in params: + lines.append("--" + boundary) + lines.append("Content-Disposition: form-data; name=\"%s\"" % name) + lines.append("") + lines.append(str(params[name])) + for name in files: + filename = files[name] + content_type, _ = mimetypes.guess_type(filename) + if content_type is None: + content_type = "application/octet-stream" + + lines.append("--" + boundary) + lines.append("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"" % (name, filename)) + lines.append("Content-Type: %s" % content_type) + lines.append("") + lines.append(open(os.path.expanduser(filename), "rb").read()) + lines.append("--" + boundary + "--") + lines.append("") + + body = "\r\n".join(lines) + headers = {'Content-Type': "multipart/form-data; boundary=%s" % boundary, + 'Content-Length': str(len(body))} + return headers, body diff --git a/openphoto/objects.py b/openphoto/objects.py new file mode 100644 index 0000000..965df9a --- /dev/null +++ b/openphoto/objects.py @@ -0,0 +1,162 @@ +from errors import * + +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 lists + (there may be more than one next/previous photo returned). + """ + result = self._openphoto.get("/photo/%s/nextprevious.json" % self.id, + **kwds)["result"] + value = {} + if "next" in result: + value["next"] = [] + for photo in result["next"]: + value["next"].append(Photo(self._openphoto, photo)) + if "previous" in result: + value["previous"] = [] + for photo in result["previous"]: + value["previous"].append(Photo(self._openphoto, photo)) + 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) + self._update_fields_with_objects() + + def _update_fields_with_objects(self): + """ Convert dict fields into objects, where appropriate """ + # Update the cover with a photo object + if hasattr(self, "cover") and isinstance(self.cover, dict): + self.cover = Photo(self._openphoto, self.cover) + # Update the photo list with photo objects + if hasattr(self, "photos") and isinstance(self.photos, list): + for i, photo in enumerate(self.photos): + if isinstance(photo, dict): + self.photos[i] = Photo(self._openphoto, photo) + + 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 """ + new_dict = 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) + + # Replace the above line with the below once frontend issue #937 is resolved +# self._set_fields(new_dict) +# self._update_fields_with_objects() + + 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"] + self._replace_fields(result) + self._update_fields_with_objects() diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py new file mode 100644 index 0000000..59431f6 --- /dev/null +++ b/openphoto/openphoto_http.py @@ -0,0 +1,188 @@ +import oauth2 as oauth +import urlparse +import urllib +import urllib2 +import httplib2 +import logging +try: + import json +except ImportError: + import simplejson as json + +from objects import OpenPhotoObject +from errors import * +from multipart_post import encode_multipart_formdata + +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 + + self._logger = logging.getLogger("openphoto") + + # Remember the most recent HTTP request and response + self.last_url = None + self.last_params = None + self.last_response = None + + def get(self, endpoint, process_response=True, **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. + Returns the raw response if process_response=False + """ + 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") + + self._logger.info("============================") + self._logger.info("GET %s" % url) + self._logger.info("---") + self._logger.info(content) + + self.last_url = url + self.last_params = params + self.last_response = content + + if process_response: + return self._process_response(content) + return response + else: + return content + + def post(self, endpoint, process_response=True, files = {}, **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. + Returns the raw response if process_response=False + """ + 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) + + if files: + # Parameters must be signed and encoded into the multipart body + params = self._sign_params(client, url, params) + headers, body = encode_multipart_formdata(params, files) + request = urllib2.Request(url, body, headers) + content = urllib2.urlopen(request).read() + else: + body = urllib.urlencode(params) + _, content = client.request(url, "POST", body) + + # TODO: Don't log file data in multipart forms + self._logger.info("============================") + self._logger.info("POST %s" % url) + if body: + self._logger.info(body) + self._logger.info("---") + self._logger.info(content) + + self.last_url = url + self.last_params = params + self.last_response = content + + if process_response: + return self._process_response(content) + else: + return content + + @staticmethod + def _sign_params(client, url, params): + """Use OAuth to sign a dictionary of params""" + request = oauth.Request.from_consumer_and_token(consumer=client.consumer, + token=client.token, + http_method="POST", + http_url=url, + parameters=params) + request.sign_request(client.method, client.consumer, client.token) + return dict(urlparse.parse_qsl(request.to_postdata())) + + @staticmethod + def _process_params(params): + """ Converts Unicode/lists/booleans inside HTTP parameters """ + processed_params = {} + for key, value in params.items(): + # Extract IDs from objects + if isinstance(value, OpenPhotoObject): + value = value.id + + # Use UTF-8 encoding + if isinstance(value, unicode): + value = value.encode('utf-8') + + # Handle lists + if isinstance(value, list): + # Make a copy of the list, to avoid overwriting the original + new_list = list(value) + # Extract IDs from objects in the list + for i, item in enumerate(new_list): + if isinstance(item, OpenPhotoObject): + new_list[i] = item.id + # Convert list to unicode string + value = u','.join([unicode(item) for item in new_list]) + + # Handle booleans + if isinstance(value, bool): + value = 1 if value else 0 + processed_params[key] = value + + return processed_params + + @staticmethod + def _process_response(content): + """ + Decodes the JSON response, returning a dict. + Raises an exception if an invalid response code is received. + """ + response = json.loads(content) + + if response["code"] >= 200 and response["code"] < 300: + # Valid response code + return response + + 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 not result: + return [] + 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', diff --git a/tests/README.markdown b/tests/README.markdown new file mode 100644 index 0000000..92e250f --- /dev/null +++ b/tests/README.markdown @@ -0,0 +1,61 @@ +Tests for the Open Photo API / Python Library +======================= +#### OpenPhoto, a photo service for the masses + +---------------------------------------- + +### Requirements +A computer, Python 2.7 and an empty OpenPhoto instance. + +--------------------------------------- + +### Setting up + +Create a tests/tokens.py file containing the following: + + # tests/tokens.py + consumer_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + consumer_secret = "xxxxxxxxxx" + token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + token_secret = "xxxxxxxxxx" + host = "your_hostname" + +Make sure this is an empty test server, **not a production OpenPhoto server!!!** + +--------------------------------------- + +### Running the tests + + cd /path/to/openphoto-python + python -m unittest discover -c + +The "-c" lets you stop the tests gracefully with \[CTRL\]-c. + +The easiest way to run a subset of the tests is with nose: + + cd /path/to/openphoto-python + nosetests -v -s tests/test_albums.py:TestAlbums.test_view + +--------------------------------------- + +### Test Details + +These tests are intended to verify the Python library. They don't provide comprehensive testing of the OpenPhoto API, there are PHP unit tests for that. + +Each test class is run as follows: + +**SetUpClass:** + +Check that the server is empty + +**SetUp:** + +Ensure there are: + + * Three test photos + * A single test tag applied to each + * A single album containing all three photos + +**TearDownClass:** + +Remove all photos, tags and albums diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_albums.py b/tests/test_albums.py new file mode 100644 index 0000000..53ff7e9 --- /dev/null +++ b/tests/test_albums.py @@ -0,0 +1,77 @@ +import unittest +import openphoto +import test_base + +class TestAlbums(test_base.TestBase): + + def test_create_delete(self): + """ Create an album then delete it """ + album_name = "create_delete_album" + album = self.client.album.create(album_name) + + # Check the return value + self.assertEqual(album.name, album_name) + # Check that the album now exists + self.assertIn(album_name, [a.name for a in self.client.albums.list()]) + + # Delete the album + self.client.album.delete(album.id) + # Check that the album is now gone + self.assertNotIn(album_name, [a.name for a in self.client.albums.list()]) + + # Create it again, and delete it using the Album object + album = self.client.album.create(album_name) + album.delete() + # Check that the album is now gone + self.assertNotIn(album_name, [a.name for a in self.client.albums.list()]) + + def test_update(self): + """ Test that an album can be updated """ + # Update the album using the OpenPhoto class, passing in the album object + new_name = "New Name" + self.client.album.update(self.albums[0], name=new_name) + + # Check that the album is updated + self.albums = self.client.albums.list() + self.assertEqual(self.albums[0].name, new_name) + + # Update the album using the OpenPhoto class, passing in the album id + new_name = "Another New Name" + self.client.album.update(self.albums[0].id, name=new_name) + + # Check that the album is updated + self.albums = self.client.albums.list() + self.assertEqual(self.albums[0].name, new_name) + + # Update the album using the Album object directly + self.albums[0].update(name=self.TEST_ALBUM) + + # Check that the album is updated + self.albums = self.client.albums.list() + self.assertEqual(self.albums[0].name, self.TEST_ALBUM) + + def test_view(self): + """ Test the album view """ + album = self.albums[0] + self.assertFalse(hasattr(album, "photos")) + + # Get the photos in the album using the Album object directly + album.view(includeElements=True) + # Make sure all photos are in the album + for photo in self.photos: + self.assertIn(photo.id, [p.id for p in album.photos]) + + def test_form(self): + """ If album.form gets implemented, write a test! """ + with self.assertRaises(openphoto.NotImplementedError): + self.client.album.form(None) + + def test_add_photos(self): + """ If album.add_photos gets implemented, write a test! """ + with self.assertRaises(openphoto.NotImplementedError): + self.client.album.add_photos(None, None) + + def test_remove_photos(self): + """ If album.remove_photos gets implemented, write a test! """ + with self.assertRaises(openphoto.NotImplementedError): + self.client.album.remove_photos(None, None) diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..ad425b1 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,140 @@ +import unittest +import logging +import openphoto + +try: + import tokens +except ImportError: + print ("********************************************************************\n" + "You need to create a 'tokens.py' file containing the following:\n\n" + " host = \"\"\n" + " consumer_key = \"\"\n" + " consumer_secret = \"\"\n" + " token = \"\"\n" + " token_secret = \"\"\n" + " host = \"\"\n\n" + "WARNING: Don't use a production OpenPhoto instance for this!\n" + "********************************************************************\n") + raise + +class TestBase(unittest.TestCase): + TEST_TITLE = "Test Image - delete me!" + TEST_TAG = "test_tag" + TEST_ALBUM = "test_album" + MAXIMUM_TEST_PHOTOS = 4 # Never have more the 4 photos on the test server + + def __init__(self, *args, **kwds): + unittest.TestCase.__init__(self, *args, **kwds) + self.photos = [] + + LOG_FILENAME = "tests.log" + logging.basicConfig(filename="tests.log", + filemode="w", + format="%(message)s", + level=logging.INFO) + + @classmethod + def setUpClass(cls): + """ Ensure there is nothing on the server before running any tests """ + cls.client = openphoto.OpenPhoto(tokens.host, + tokens.consumer_key, tokens.consumer_secret, + tokens.token, tokens.token_secret) + + if cls.client.photos.list() != []: + raise ValueError("The test server (%s) contains photos. " + "Please delete them before running the tests" + % tokens.host) + + if cls.client.tags.list() != []: + raise ValueError("The test server (%s) contains tags. " + "Please delete them before running the tests" + % tokens.host) + + if cls.client.albums.list() != []: + raise ValueError("The test server (%s) contains albums. " + "Please delete them before running the tests" + % tokens.host) + + @classmethod + def tearDownClass(cls): + """ Once all tests have finished, delete all photos, tags and albums""" + cls._delete_all() + + def setUp(self): + """ + Ensure the three test photos are present before each test. + Give them each a tag. + Put them into an album. + """ + self.photos = self.client.photos.list() + if len(self.photos) != 3: +# print self.photos + print "[Regenerating Photos]" + if len(self.photos) > 0: + self._delete_all() + self._create_test_photos() + self.photos = self.client.photos.list() + + self.tags = self.client.tags.list() + if (len(self.tags) != 1 or + self.tags[0].id != self.TEST_TAG or + self.tags[0].count != 3): + print "[Regenerating Tags]" + self._delete_all() + self._create_test_photos() + self.photos = self.client.photos.list() + self.tags = self.client.tags.list() + if len(self.tags) != 1: + print "Tags: %s" % self.tags + raise Exception("Tag creation failed") + + self.albums = self.client.albums.list() + if (len(self.albums) != 1 or + self.albums[0].name != self.TEST_ALBUM or + self.albums[0].count != "3"): + print "[Regenerating Albums]" + self._delete_all() + self._create_test_photos() + self.photos = self.client.photos.list() + self.tags = self.client.tags.list() + self.albums = self.client.albums.list() + if len(self.albums) != 1: + print "Albums: %s" % self.albums + raise Exception("Album creation failed") + + logging.info("\nRunning %s..." % self.id()) + + def tearDown(self): + logging.info("Finished %s\n" % self.id()) + + @classmethod + def _create_test_photos(cls): + """ Upload three test photos """ + album = cls.client.album.create(cls.TEST_ALBUM) + photos = [ + cls.client.photo.upload("tests/test_photo1.jpg", + title=cls.TEST_TITLE, + albums=album.id), + cls.client.photo.upload("tests/test_photo2.jpg", + title=cls.TEST_TITLE, + albums=album.id), + cls.client.photo.upload("tests/test_photo3.jpg", + title=cls.TEST_TITLE, + albums=album.id), + ] + # Add the test tag, removing any autogenerated tags + for photo in photos: + photo.update(tags=cls.TEST_TAG) + + @classmethod + def _delete_all(cls): + photos = cls.client.photos.list() + if len(photos) > cls.MAXIMUM_TEST_PHOTOS: + raise ValueError("There too many photos on the test server - must always be less than %d." + % cls.MAXIMUM_TEST_PHOTOS) + for photo in photos: + photo.delete() + for tag in cls.client.tags.list(): + tag.delete() + for album in cls.client.albums.list(): + album.delete() diff --git a/tests/test_photo1.jpg b/tests/test_photo1.jpg new file mode 100644 index 0000000..6363b19 Binary files /dev/null and b/tests/test_photo1.jpg differ diff --git a/tests/test_photo2.jpg b/tests/test_photo2.jpg new file mode 100644 index 0000000..1351a47 Binary files /dev/null and b/tests/test_photo2.jpg differ diff --git a/tests/test_photo3.jpg b/tests/test_photo3.jpg new file mode 100644 index 0000000..05fe10b Binary files /dev/null and b/tests/test_photo3.jpg differ diff --git a/tests/test_photos.py b/tests/test_photos.py new file mode 100644 index 0000000..ffddbfe --- /dev/null +++ b/tests/test_photos.py @@ -0,0 +1,152 @@ +import unittest +import openphoto +import test_base + +class TestPhotos(test_base.TestBase): + def test_delete_upload(self): + """ Test photo deletion and upload """ + # Delete one photo using the OpenPhoto class, passing in the id + self.client.photo.delete(self.photos[0].id) + # Delete one photo using the OpenPhoto class, passing in the object + self.client.photo.delete(self.photos[1]) + # And another using the Photo object directly + self.photos[2].delete() + + # Check that they're gone + self.assertEqual(self.client.photos.list(), []) + + # Re-upload the photos, one of them using Bas64 encoding + ret_val = self.client.photo.upload("tests/test_photo1.jpg", + title=self.TEST_TITLE) + self.client.photo.upload("tests/test_photo2.jpg", + title=self.TEST_TITLE) + self.client.photo.upload_encoded("tests/test_photo3.jpg", + title=self.TEST_TITLE) + + # Check there are now three photos + self.photos = self.client.photos.list() + self.assertEqual(len(self.photos), 3) + + # Check that the upload return value was correct + pathOriginals = [photo.pathOriginal for photo in self.photos] + self.assertIn(ret_val.pathOriginal, pathOriginals) + + # Delete all photos in one go + self.client.photos.delete(self.photos) + + # Check they're gone + self.photos = self.client.photos.list() + self.assertEqual(len(self.photos), 0) + + # Regenerate the original test photos + self._delete_all() + self._create_test_photos() + + def test_edit(self): + """ Check that the edit request returns an HTML form """ + # Test using the OpenPhoto class + html = self.client.photo.edit(self.photos[0]) + self.assertIn("