diff --git a/README.markdown b/README.markdown index b3623ee..619b7fa 100644 --- a/README.markdown +++ b/README.markdown @@ -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//update.json`` + +### 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) + ---------------------------------------- diff --git a/openphoto/__init__.py b/openphoto/__init__.py index 295940c..ce569c5 100644 --- a/openphoto/__init__.py +++ b/openphoto/__init__.py @@ -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) diff --git a/openphoto/api_album.py b/openphoto/api_album.py index e997b65..9777a30 100644 --- a/openphoto/api_album.py +++ b/openphoto/api_album.py @@ -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): """ diff --git a/openphoto/api_photo.py b/openphoto/api_photo.py index fed9c8a..18ae68c 100644 --- a/openphoto/api_photo.py +++ b/openphoto/api_photo.py @@ -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 diff --git a/openphoto/api_tag.py b/openphoto/api_tag.py index 89f9fee..bc87d32 100644 --- a/openphoto/api_tag.py +++ b/openphoto/api_tag.py @@ -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 """ diff --git a/openphoto/errors.py b/openphoto/errors.py index 25a0b24..218fd35 100644 --- a/openphoto/errors.py +++ b/openphoto/errors.py @@ -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 diff --git a/openphoto/objects.py b/openphoto/objects.py index 965df9a..589bbec 100644 --- a/openphoto/objects.py +++ b/openphoto/objects.py @@ -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): """ diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 066a3bc..0657161 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -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): diff --git a/tests/README.markdown b/tests/README.markdown index 4d722a4..26813ee 100644 --- a/tests/README.markdown +++ b/tests/README.markdown @@ -9,7 +9,7 @@ A computer, Python 2.7 and an empty OpenPhoto test host. --------------------------------------- -### 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". --------------------------------------- diff --git a/tests/api_versions/__init__.py b/tests/api_versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_versions/test_v1.py b/tests/api_versions/test_v1.py new file mode 100644 index 0000000..92baabb --- /dev/null +++ b/tests/api_versions/test_v1.py @@ -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 diff --git a/tests/api_versions/test_v2.py b/tests/api_versions/test_v2.py new file mode 100644 index 0000000..a6cfa4e --- /dev/null +++ b/tests/api_versions/test_v2.py @@ -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 diff --git a/tests/test_albums.py b/tests/test_albums.py index 53ff7e9..bc2de51 100644 --- a/tests/test_albums.py +++ b/tests/test_albums.py @@ -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()]) diff --git a/tests/test_base.py b/tests/test_base.py index 2494e5b..94b8d4a 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -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() diff --git a/tests/test_framework.py b/tests/test_framework.py new file mode 100644 index 0000000..b28fc23 --- /dev/null +++ b/tests/test_framework.py @@ -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") diff --git a/tests/test_photos.py b/tests/test_photos.py index ffddbfe..35e69f8 100644 --- a/tests/test_photos.py +++ b/tests/test_photos.py @@ -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") diff --git a/tests/test_tags.py b/tests/test_tags.py index 02ea449..17b0eca 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -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")