Merge branch 'master' into class_config

Conflicts:
	openphoto/__init__.py
	openphoto/openphoto_http.py
	tests/test_base.py
This commit is contained in:
sneakypete81 2013-05-06 17:25:36 +01:00
commit 42999f80f5
17 changed files with 293 additions and 109 deletions

View file

@ -59,6 +59,17 @@ The OpenPhoto Python class hierarchy mirrors the [OpenPhoto API](http://theopenp
* ``client.photos.list() -> /photos/list.json``
* ``photos[0].update() -> /photo/<id>/update.json``
<a name="api_versioning"></a>
### API Versioning
It may be useful to lock your application to a particular version of the OpenPhoto API.
This ensures that future API updates won't cause unexpected breakages.
To do this, add the optional ```api_version``` parameter when creating the client object:
from openphoto import OpenPhoto
client = OpenPhoto(api_version=2)
----------------------------------------
<a name="cli"></a>

View file

@ -4,20 +4,27 @@ import api_photo
import api_tag
import api_album
LATEST_API_VERSION = 2
class OpenPhoto(OpenPhotoHttp):
"""
Client library for OpenPhoto
If no parameters are specified, config is loaded from the default
location (~/.config/openphoto/default).
The config_file parameter is used to specify an alternate config file.
If the host parameter is specified, no config file is loaded.
If the host parameter is specified, no config file is loaded and
OAuth tokens (consumer*, token*) can optionally be specified.
All requests will include the api_version path, if specified.
This should be used to ensure that your application will continue to work
even if the OpenPhoto API is updated to a new revision.
"""
def __init__(self, config_file=None, host=None,
consumer_key='', consumer_secret='',
token='', token_secret=''):
token='', token_secret='',
api_version=None):
OpenPhotoHttp.__init__(self, config_file, host,
consumer_key, consumer_secret,
token, token_secret)
token, token_secret, api_version)
self.photos = api_photo.ApiPhotos(self)
self.photo = api_photo.ApiPhoto(self)

View file

@ -20,11 +20,15 @@ class ApiAlbum:
return Album(self._client, result)
def delete(self, album, **kwds):
""" Delete an album """
"""
Delete an album.
Returns True if successful.
Raises an OpenPhotoError if not.
"""
if not isinstance(album, Album):
album = Album(self._client, {"id": album})
album.delete(**kwds)
return album.delete(**kwds)
def form(self, album, **kwds):
raise NotImplementedError()
@ -39,10 +43,7 @@ class ApiAlbum:
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
return album
def view(self, album, **kwds):
"""

View file

@ -14,25 +14,38 @@ class ApiPhotos:
return [Photo(self._client, photo) for photo in photos]
def update(self, photos, **kwds):
""" Updates a list of photos """
"""
Updates a list of photos.
Returns True if successful.
Raises OpenPhotoError if not.
"""
if not self._client.post("/photos/update.json", ids=photos, **kwds)["result"]:
raise OpenPhotoError("Update response returned False")
return True
def delete(self, photos, **kwds):
""" Deletes a list of photos """
"""
Deletes a list of photos.
Returns True if successful.
Raises OpenPhotoError if not.
"""
if not self._client.post("/photos/delete.json", ids=photos, **kwds)["result"]:
raise OpenPhotoError("Delete response returned False")
return True
class ApiPhoto:
def __init__(self, client):
self._client = client
def delete(self, photo, **kwds):
""" Delete a photo """
"""
Delete a photo.
Returns True if successful.
Raises an OpenPhotoError if not.
"""
if not isinstance(photo, Photo):
photo = Photo(self._client, {"id": photo})
photo.delete(**kwds)
return photo.delete(**kwds)
def edit(self, photo, **kwds):
""" Returns an HTML form to edit a photo """
@ -91,4 +104,11 @@ class ApiPhoto:
return photo.next_previous(**kwds)
def transform(self, photo, **kwds):
raise NotImplementedError()
"""
Performs transformation specified in **kwds
Example: transform(photo, rotate=90)
"""
if not isinstance(photo, Photo):
photo = Photo(self._client, {"id": photo})
photo.transform(**kwds)
return photo

View file

@ -15,15 +15,18 @@ class ApiTag:
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)
""" Create a new tag. The API returns true if the tag was sucessfully created """
return self._client.post("/tag/create.json", tag=tag, **kwds)["result"]
def delete(self, tag, **kwds):
""" Delete a tag """
"""
Delete a tag.
Returns True if successful.
Raises an OpenPhotoError if not.
"""
if not isinstance(tag, Tag):
tag = Tag(self._client, {"id": tag})
tag.delete(**kwds)
return tag.delete(**kwds)
def update(self, tag, **kwds):
""" Update a tag """

View file

@ -6,6 +6,10 @@ class OpenPhotoDuplicateError(OpenPhotoError):
""" Indicates that an upload operation failed due to a duplicate photo """
pass
class OpenPhoto404Error(Exception):
""" Indicates that an Http 404 error code was received (resource not found) """
pass
class NotImplementedError(OpenPhotoError):
""" Indicates that the API function has not yet been coded - please help! """
pass

View file

@ -1,3 +1,4 @@
import urllib
from errors import *
class OpenPhotoObject:
@ -39,9 +40,14 @@ class OpenPhotoObject:
class Photo(OpenPhotoObject):
def delete(self, **kwds):
""" Delete this photo """
self._openphoto.post("/photo/%s/delete.json" % self.id, **kwds)
"""
Delete this photo.
Returns True if successful.
Raises an OpenPhotoError if not.
"""
result = self._openphoto.post("/photo/%s/delete.json" % self.id, **kwds)["result"]
self._replace_fields({})
return result
def edit(self, **kwds):
""" Returns an HTML form to edit the photo """
@ -82,28 +88,48 @@ class Photo(OpenPhotoObject):
**kwds)["result"]
value = {}
if "next" in result:
# Workaround for APIv1
if not isinstance(result["next"], list):
result["next"] = [result["next"]]
value["next"] = []
for photo in result["next"]:
value["next"].append(Photo(self._openphoto, photo))
if "previous" in result:
# Workaround for APIv1
if not isinstance(result["previous"], list):
result["previous"] = [result["previous"]]
value["previous"] = []
for photo in result["previous"]:
value["previous"].append(Photo(self._openphoto, photo))
return value
def transform(self, **kwds):
raise NotImplementedError()
"""
Performs transformation specified in **kwds
Example: transform(rotate=90)
"""
new_dict = self._openphoto.post("/photo/%s/transform.json" % self.id,
**kwds)["result"]
self._replace_fields(new_dict)
class Tag(OpenPhotoObject):
def delete(self, **kwds):
""" Delete this tag """
self._openphoto.post("/tag/%s/delete.json" % self.id, **kwds)
"""
Delete this tag.
Returns True if successful.
Raises an OpenPhotoError if not.
"""
result = self._openphoto.post("/tag/%s/delete.json" % urllib.quote(self.id), **kwds)["result"]
self._replace_fields({})
return result
def update(self, **kwds):
""" Update this tag with the specified parameters """
new_dict = self._openphoto.post("/tag/%s/update.json" % self.id,
new_dict = self._openphoto.post("/tag/%s/update.json" % urllib.quote(self.id),
**kwds)["result"]
self._replace_fields(new_dict)
@ -125,9 +151,14 @@ class Album(OpenPhotoObject):
self.photos[i] = Photo(self._openphoto, photo)
def delete(self, **kwds):
""" Delete this album """
self._openphoto.post("/album/%s/delete.json" % self.id, **kwds)
"""
Delete this album.
Returns True if successful.
Raises an OpenPhotoError if not.
"""
result = self._openphoto.post("/album/%s/delete.json" % self.id, **kwds)["result"]
self._replace_fields({})
return result
def form(self, **kwds):
raise NotImplementedError()
@ -142,14 +173,8 @@ class Album(OpenPhotoObject):
""" 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()
self._replace_fields(new_dict)
self._update_fields_with_objects()
def view(self, **kwds):
"""

View file

@ -2,7 +2,6 @@ import os
import oauth2 as oauth
import urlparse
import urllib
import urllib2
import httplib2
import logging
import StringIO
@ -25,11 +24,16 @@ class OpenPhotoHttp:
If no parameters are specified, config is loaded from the default
location (~/.config/openphoto/default).
The config_file parameter is used to specify an alternate config file.
If the host parameter is specified, no config file is loaded.
If the host parameter is specified, no config file is loaded and
OAuth tokens (consumer*, token*) can optionally be specified.
All requests will include the api_version path, if specified.
This should be used to ensure that your application will continue to work
even if the OpenPhoto API is updated to a new revision.
"""
def __init__(self, config_file=None, host=None,
consumer_key='', consumer_secret='',
token='', token_secret=''):
token='', token_secret='', api_version=None):
self._api_version = api_version
self._logger = logging.getLogger("openphoto")
@ -60,11 +64,18 @@ class OpenPhotoHttp:
"""
Performs an HTTP GET from the specified endpoint (API path),
passing parameters if given.
The api_version is prepended to the endpoint,
if it was specified when the OpenPhoto object was created.
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)
if not endpoint.startswith("/"):
endpoint = "/" + endpoint
if self._api_version is not None:
endpoint = "/v%d%s" % (self._api_version, endpoint)
url = urlparse.urlunparse(('http', self._host, endpoint, '',
urllib.urlencode(params), ''))
if self._consumer_key:
@ -74,7 +85,7 @@ class OpenPhotoHttp:
else:
client = httplib2.Http()
_, content = client.request(url, "GET")
response, content = client.request(url, "GET")
self._logger.info("============================")
self._logger.info("GET %s" % url)
@ -83,11 +94,10 @@ class OpenPhotoHttp:
self.last_url = url
self.last_params = params
self.last_response = content
self.last_response = (response, content)
if process_response:
return self._process_response(content)
return response
return self._process_response(response, content)
else:
return content
@ -95,13 +105,20 @@ class OpenPhotoHttp:
"""
Performs an HTTP POST to the specified endpoint (API path),
passing parameters if given.
The api_version is prepended to the endpoint,
if it was specified when the OpenPhoto object was created.
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)
if not endpoint.startswith("/"):
endpoint = "/" + endpoint
if self._api_version is not None:
endpoint = "/v%d%s" % (self._api_version, endpoint)
url = urlparse.urlunparse(('http', self._host, endpoint, '', '', ''))
if not self._consumer_key:
raise OpenPhotoError("Cannot issue POST without OAuth tokens")
@ -111,28 +128,28 @@ class OpenPhotoHttp:
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()
signed_params = self._sign_params(client, url, params)
headers, body = encode_multipart_formdata(signed_params, files)
else:
body = urllib.urlencode(params)
_, content = client.request(url, "POST", body)
headers = None
response, content = client.request(url, "POST", body, headers)
# 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("params: %s" % repr(params))
if files:
self._logger.info("files: %s" % repr(files))
self._logger.info("---")
self._logger.info(content)
self.last_url = url
self.last_params = params
self.last_response = content
self.last_response = (response, content)
if process_response:
return self._process_response(content)
return self._process_response(response, content)
else:
return content
@ -179,26 +196,32 @@ class OpenPhotoHttp:
return processed_params
@staticmethod
def _process_response(content):
def _process_response(response, content):
"""
Decodes the JSON response, returning a dict.
Raises an exception if an invalid response code is received.
"""
response = json.loads(content)
try:
json_response = json.loads(content)
code = json_response["code"]
message = json_response["message"]
except ValueError, KeyError:
# Response wasn't OpenPhoto JSON - check the HTTP status code
if 200 <= response.status < 300:
# Status code was valid, so just reraise the exception
raise
elif response.status == 404:
raise OpenPhoto404Error("HTTP Error %d: %s" % (response.status, response.reason))
else:
raise OpenPhotoError("HTTP Error %d: %s" % (response.status, response.reason))
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)
if 200 <= code < 300:
return json_response
elif (code == DUPLICATE_RESPONSE["code"] and
DUPLICATE_RESPONSE["message"] in message):
raise OpenPhotoDuplicateError("Code %d: %s" % (code, message))
else:
raise OpenPhotoError("Code %d: %s" % (code, message))
@staticmethod
def _result_to_list(result):

View file

@ -9,7 +9,7 @@ A computer, Python 2.7 and an empty OpenPhoto test host.
---------------------------------------
<a name="setup"></a>
### Setting up
### Setting up
Create a ``~/.config/openphoto/test`` config file containing the following:
@ -38,7 +38,9 @@ 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
nosetests -v -s --nologcapture tests/test_albums.py:TestAlbums.test_view
All HTTP requests and responses are recorded in the file "tests.log".
---------------------------------------
<a name="test_details"></a>

View file

View file

@ -0,0 +1,10 @@
from tests import test_albums, test_photos, test_tags
class TestAlbumsV1(test_albums.TestAlbums):
api_version = 1
class TestPhotosV1(test_photos.TestPhotos):
api_version = 1
class TestTagsV1(test_tags.TestTags):
api_version = 1

View file

@ -0,0 +1,10 @@
from tests import test_albums, test_photos, test_tags
class TestAlbumsV2(test_albums.TestAlbums):
api_version = 2
class TestPhotosV2(test_photos.TestPhotos):
api_version = 2
class TestTagsV2(test_tags.TestTags):
api_version = 2

View file

@ -3,6 +3,7 @@ import openphoto
import test_base
class TestAlbums(test_base.TestBase):
testcase_name = "album API"
def test_create_delete(self):
""" Create an album then delete it """
@ -15,13 +16,13 @@ class TestAlbums(test_base.TestBase):
self.assertIn(album_name, [a.name for a in self.client.albums.list()])
# Delete the album
self.client.album.delete(album.id)
self.assertTrue(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()
self.assertTrue(album.delete())
# Check that the album is now gone
self.assertNotIn(album_name, [a.name for a in self.client.albums.list()])

View file

@ -8,12 +8,14 @@ class TestBase(unittest.TestCase):
TEST_TAG = "test_tag"
TEST_ALBUM = "test_album"
MAXIMUM_TEST_PHOTOS = 4 # Never have more the 4 photos on the test server
testcase_name = "(unknown testcase)"
api_version = None
config_file = os.getenv("OPENPHOTO_TEST_CONFIG", "test")
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",
@ -22,8 +24,13 @@ class TestBase(unittest.TestCase):
@classmethod
def setUpClass(cls):
""" Ensure there is nothing on the server before running any tests """
config_file = os.getenv("OPENPHOTO_TEST_CONFIG", "test")
cls.client = openphoto.OpenPhoto(config_file=config_file)
if cls.api_version is None:
print "\nTesting Latest %s" % cls.testcase_name
else:
print "\nTesting %s v%d" % (cls.testcase_name, cls.api_version)
cls.client = openphoto.OpenPhoto(config_file=cls.config_file,
api_version=cls.api_version)
if cls.client.photos.list() != []:
raise ValueError("The test server (%s) contains photos. "
@ -63,7 +70,7 @@ class TestBase(unittest.TestCase):
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):
str(self.tags[0].count) != "3"):
print "[Regenerating Tags]"
self._delete_all()
self._create_test_photos()

44
tests/test_framework.py Normal file
View file

@ -0,0 +1,44 @@
import unittest
import logging
import openphoto
import test_base
class TestFramework(test_base.TestBase):
testcase_name = "framework"
def setUp(self):
"""Override the default setUp, since we don't need a populated database"""
logging.info("\nRunning %s..." % self.id())
def test_api_version_zero(self):
# API v0 has a special hello world message
client = openphoto.OpenPhoto(config_file=self.config_file,
api_version=0)
result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world! This is version zero of the API!")
self.assertEqual(result['result']['__route__'], "/v0/hello.json")
def test_specified_api_version(self):
# For all API versions >0, we get a generic hello world message
for api_version in range(1, openphoto.LATEST_API_VERSION + 1):
client = openphoto.OpenPhoto(config_file=self.config_file,
api_version=api_version)
result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world!")
self.assertEqual(result['result']['__route__'], "/v%d/hello.json" % api_version)
def test_unspecified_api_version(self):
# If the API version is unspecified, we get a generic hello world message
client = openphoto.OpenPhoto(config_file=self.config_file,
api_version=None)
result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world!")
self.assertEqual(result['result']['__route__'], "/hello.json")
def test_future_api_version(self):
# If the API version is unsupported, we should get an error
# (it's a ValueError, since the returned 404 HTML page is not valid JSON)
client = openphoto.OpenPhoto(config_file=self.config_file,
api_version=openphoto.LATEST_API_VERSION + 1)
with self.assertRaises(openphoto.OpenPhoto404Error):
client.get("hello.json")

View file

@ -3,14 +3,16 @@ import openphoto
import test_base
class TestPhotos(test_base.TestBase):
testcase_name = "photo API"
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)
self.assertTrue(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])
self.assertTrue(self.client.photo.delete(self.photos[1]))
# And another using the Photo object directly
self.photos[2].delete()
self.assertTrue(self.photos[2].delete())
# Check that they're gone
self.assertEqual(self.client.photos.list(), [])
@ -23,16 +25,18 @@ class TestPhotos(test_base.TestBase):
self.client.photo.upload_encoded("tests/test_photo3.jpg",
title=self.TEST_TITLE)
# Check there are now three photos
# Check there are now three photos with the correct titles
self.photos = self.client.photos.list()
self.assertEqual(len(self.photos), 3)
for photo in self.photos:
self.assertEqual(photo.title, self.TEST_TITLE)
# 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)
self.assertTrue(self.client.photos.delete(self.photos))
# Check they're gone
self.photos = self.client.photos.list()
@ -147,6 +151,12 @@ class TestPhotos(test_base.TestBase):
self.client.photo.dynamic_url(None)
def test_transform(self):
""" If photo.transform gets implemented, write a test! """
with self.assertRaises(openphoto.NotImplementedError):
self.client.photo.transform(None)
""" Test photo rotation """
photo = self.photos[0]
self.assertEqual(photo.rotation, "0")
photo = self.client.photo.transform(photo, rotate=90)
self.assertEqual(photo.rotation, "90")
# Do the same using the Photo object directly
photo.transform(rotate=90)
self.assertEqual(photo.rotation, "180")

View file

@ -3,31 +3,35 @@ import openphoto
import test_base
class TestTags(test_base.TestBase):
@unittest.expectedFailure # Tag create fails - Issue #927
# NOTE: the below has not been tested/debugged, since it fails at the first step
def test_create_delete(self, tag_name="create_tag"):
testcase_name = "tag API"
def test_create_delete(self, tag_id="create_tag"):
""" Create a tag then delete it """
# Create a tag
tag = self.client.tag.create(tag_name)
self.assertTrue(self.client.tag.create(tag_id))
# Check that the tag doesn't exist (It has no photos, so it's invisible)
self.assertNotIn(tag_id, [t.id for t in self.client.tags.list()])
# Check the return value
self.assertEqual(tag.id, tag_name)
# Create a tag on one of the photos
self.photos[0].update(tagsAdd=tag_id)
# Check that the tag now exists
self.assertIn(tag_name, self.client.tags.list())
self.assertIn(tag_id, [t.id for t in self.client.tags.list()])
# Delete the tag
self.client.tag.delete(tag_name)
self.assertTrue(self.client.tag.delete(tag_id))
# Check that the tag is now gone
self.assertNotIn(tag_name, self.client.tags.list())
self.assertNotIn(tag_id, [t.id for t in self.client.tags.list()])
# Create and delete using the Tag object directly
tag = self.client.tag.create(tag_name)
tag.delete()
# Create then delete using the Tag object directly
self.photos[0].update(tagsAdd=tag_id)
tag = [t for t in self.client.tags.list() if t.id == tag_id][0]
self.assertTrue(tag.delete())
# Check that the tag is now gone
self.assertNotIn(tag_name, self.client.tags.list())
self.assertNotIn(tag_id, [t.id for t in self.client.tags.list()])
@unittest.expectedFailure # Tag update fails - Issue #927
# NOTE: the below has not been tested/debugged, since it fails at the first step
# TODO: Un-skip and update this tests once there are tag fields that can be updated.
# The owner field cannot be updated.
@unittest.skip("Can't test the tag.update endpoint, since there are no fields that can be updated")
def test_update(self):
""" Test that a tag can be updated """
# Update the tag using the OpenPhoto class, passing in the tag object
@ -57,15 +61,17 @@ class TestTags(test_base.TestBase):
self.assertEqual(self.tags[0].owner, owner)
self.assertEqual(ret_val.owner, owner)
@unittest.expectedFailure # Tag create fails - Issue #927
# NOTE: the below has not been tested/debugged, since it fails at the first step
def test_tag_with_spaces(self):
""" Run test_create_delete using a tag containing spaces """
self.test_create_delete("tag with spaces")
# We mustn't run this test until Issue #919 is resolved,
# since it creates an undeletable tag
@unittest.skip("Tags with double-slashes cannot be deleted - Issue #919")
def test_tag_with_double_slashes(self):
def test_tag_with_slashes(self):
""" Run test_create_delete using a tag containing slashes """
self.test_create_delete("tag/with//slashes")
self.test_create_delete("tag/with/slashes")
# TODO: Un-skip this test once issue #919 is resolved -
# tags with double-slashes cannot be deleted
@unittest.expectedFailure
def test_tag_with_double_slashes(self):
""" Run test_create_delete using a tag containing double-slashes """
self.test_create_delete("tag//with//double//slashes")