diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..c379d13 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +# .coveragerc to control coverage.py +[run] +# Capture branch coverage +branch = True diff --git a/.gitignore b/.gitignore index c780865..133ea6d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist tests/tokens.py tests.log .tox +.coverage +htmlcov diff --git a/.travis.yml b/.travis.yml index 6891aed..80c2f98 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,23 @@ language: python install: + # Install test dependencies - pip install tox --use-mirrors - - .travis/install_pylint + - pip install coveralls --use-mirrors script: tox +after_success: + # Send coverage results to coveralls.io + - coveralls + after_script: # Install dependencies for Pylint - - pip install requests requests-oauthlib + - pip install pylint-patcher --use-mirrors + - pip install requests --use-mirrors + - pip install requests-oauthlib --use-mirrors # Run Pylint + # Uses pylint-patcher to allow exceptions to be stored in a patchfile # (for information only, any errors don't affect the Travis result) - - pylint --use-ignore-patch=y trovebox + - pylint-patcher trovebox diff --git a/.travis/install_pylint b/.travis/install_pylint deleted file mode 100755 index 0f5ce45..0000000 --- a/.travis/install_pylint +++ /dev/null @@ -1,10 +0,0 @@ -# Until the --use-ignore-patch makes it into pylint upstream, we need to -# download and install from sneakypete81's pylint fork - -wget https://bitbucket.org/sneakypete81/pylint/get/use_ignore_patch.zip -unzip use_ignore_patch.zip -cd sneakypete81-pylint-* -python setup.py install - -cd .. -rm -r sneakypete81-pylint-* diff --git a/CHANGELOG b/CHANGELOG index d700b26..bcc9000 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,12 @@ Trovebox Python Library Changelog ================================= +v0.6 +====== + * Support for many additional API endpoints (#56, #65) + * Code coverage reporting (#57) + * Unit test improvements (#58, #63, #64) + v0.5.1 ====== * Use httpretty v0.6.5 for unit tests (#60) diff --git a/README.rst b/README.rst index c656a89..34a480a 100644 --- a/README.rst +++ b/README.rst @@ -7,6 +7,13 @@ Trovebox Python Library :alt: Build Status :target: https://travis-ci.org/photo/openphoto-python +.. + (commented out until master is on coveralls.io) + .. image:: https://coveralls.io/repos/photo/openphoto-python/badge.png?branch=master + :alt: Coverage Status + :target: https://coveralls.io/r/photo/openphoto-python?branch=master +.. + .. image:: https://pypip.in/v/trovebox/badge.png :alt: Python Package Index (PyPI) :target: https://pypi.python.org/pypi/trovebox diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..e417055 --- /dev/null +++ b/pylintrc @@ -0,0 +1,16 @@ +[MESSAGES CONTROL] +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time. See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=locally-disabled diff --git a/run_functional_tests b/run_functional_tests index ee3c8fa..35d58a3 100755 --- a/run_functional_tests +++ b/run_functional_tests @@ -9,26 +9,18 @@ tput setaf 3 echo echo "Testing latest self-hosted site..." tput sgr0 +sleep 1 export TROVEBOX_TEST_CONFIG=test unset TROVEBOX_TEST_SERVER_API python -m unittest discover --catch tests/functional -# Test server running APIv1 Trovebox instance -# Install from photo/frontend commit 660b2ab -tput setaf 3 -echo -echo "Testing APIv1 self-hosted site..." -tput sgr0 -export TROVEBOX_TEST_CONFIG=test-apiv1 -export TROVEBOX_TEST_SERVER_API=1 -python -m unittest discover --catch tests/functional - # Test server running v3.0.8 Trovebox instance # Install from photo/frontend commit e9d81de57b tput setaf 3 echo echo "Testing v3.0.8 self-hosted site..." tput sgr0 +sleep 1 export TROVEBOX_TEST_CONFIG=test-3.0.8 unset TROVEBOX_TEST_SERVER_API python -m unittest discover --catch tests/functional @@ -38,7 +30,18 @@ tput setaf 3 echo echo "Testing latest hosted site..." tput sgr0 +sleep 1 export TROVEBOX_TEST_CONFIG=test-hosted unset TROVEBOX_TEST_SERVER_API python -m unittest discover --catch tests/functional +# Test account on hosted trovebox.com site over HTTPS +tput setaf 3 +echo +echo "Testing latest hosted site over HTTPS..." +tput sgr0 +sleep 1 +export TROVEBOX_TEST_CONFIG=test-hosted-https +unset TROVEBOX_TEST_SERVER_API +python -m unittest discover --catch tests/functional + diff --git a/setup.py b/setup.py index 034d3c9..cf58e4b 100755 --- a/setup.py +++ b/setup.py @@ -32,8 +32,7 @@ long_description=open("README.rst").read(), author='Pete Burgers, James Walker', url='https://github.com/photo/openphoto-python', - packages=['trovebox'], - data_files=['README.rst'], + packages=['trovebox', 'trovebox.objects', 'trovebox.api'], keywords=['openphoto', 'pyopenphoto', 'openphoto-python', 'trovebox', 'pytrovebox', 'trovebox-python'], classifiers=['Development Status :: 4 - Beta', diff --git a/tests/functional/README.markdown b/tests/functional/README.markdown index 5a22abc..734a2a4 100644 --- a/tests/functional/README.markdown +++ b/tests/functional/README.markdown @@ -99,7 +99,7 @@ all supported API versions. To use it, you must set up multiple Trovebox instances and create the following config files containing your credentials: - test : Latest self-hosted site (from photo/frontend master branch) - test-apiv1 : APIv1 self-hosted site (from photo/frontend commit 660b2ab) - test-3.0.8 : v3.0.8 self-hosted site (from photo/frontend commit e9d81de57b) - test-hosted : Credentials for test account on trovebox.com + test : Latest self-hosted site (from photo/frontend master branch) + test-3.0.8 : v3.0.8 self-hosted site (from photo/frontend commit e9d81de57b) + test-hosted : Credentials for test account on http://.trovebox.com + test-hosted-https : Same as test-hosted, but with https:// diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py index e69de29..651585a 100644 --- a/tests/functional/__init__.py +++ b/tests/functional/__init__.py @@ -0,0 +1,2 @@ +# __init__.py + diff --git a/tests/functional/api_versions/__init__.py b/tests/functional/api_versions/__init__.py index e69de29..651585a 100644 --- a/tests/functional/api_versions/__init__.py +++ b/tests/functional/api_versions/__init__.py @@ -0,0 +1,2 @@ +# __init__.py + diff --git a/tests/functional/api_versions/test_v1.py b/tests/functional/api_versions/test_v1.py index aa3c652..621602d 100644 --- a/tests/functional/api_versions/test_v1.py +++ b/tests/functional/api_versions/test_v1.py @@ -1,5 +1,12 @@ +from tests.functional import test_activities, test_actions from tests.functional import test_albums, test_photos, test_tags +class TestActivitiesV1(test_activities.TestActivities): + api_version = 1 + +class TestActionsV1(test_actions.TestActions): + api_version = 1 + class TestAlbumsV1(test_albums.TestAlbums): api_version = 1 diff --git a/tests/functional/api_versions/test_v2.py b/tests/functional/api_versions/test_v2.py index a2c425c..80b204e 100644 --- a/tests/functional/api_versions/test_v2.py +++ b/tests/functional/api_versions/test_v2.py @@ -2,7 +2,19 @@ import unittest2 as unittest except ImportError: import unittest -from tests.functional import test_base, test_albums, test_photos, test_tags + +from tests.functional import test_base, test_activities, test_actions +from tests.functional import test_albums, test_photos, test_tags + +@unittest.skipIf(test_base.get_test_server_api() < 2, + "Don't test future API versions") +class TestActivitiesV2(test_activities.TestActivities): + api_version = 2 + +@unittest.skipIf(test_base.get_test_server_api() < 2, + "Don't test future API versions") +class TestActionsV2(test_actions.TestActions): + api_version = 2 @unittest.skipIf(test_base.get_test_server_api() < 2, "Don't test future API versions") diff --git a/tests/functional/test_actions.py b/tests/functional/test_actions.py new file mode 100644 index 0000000..b4c298a --- /dev/null +++ b/tests/functional/test_actions.py @@ -0,0 +1,23 @@ +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import trovebox +from tests.functional import test_base + +class TestActions(test_base.TestBase): + testcase_name = "action API" + + def test_create_view_delete(self): + """ Create an action on a photo, view it, then delete it """ + # Create and check that the action exists + action = self.client.action.create(target=self.photos[0], type="comment", name="test") + action_id = action.id + self.assertEqual(self.client.action.view(action_id).name, "test") + + # Delete and check that the action is gone + action.delete() + with self.assertRaises(trovebox.TroveboxError): + self.client.action.view(action_id) + diff --git a/tests/functional/test_activities.py b/tests/functional/test_activities.py new file mode 100644 index 0000000..43e3af4 --- /dev/null +++ b/tests/functional/test_activities.py @@ -0,0 +1,84 @@ +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +from tests.functional import test_base + +@unittest.skipIf(test_base.get_test_server_api() == 1, + ("Activities never get deleted in v1, which makes " + "these tests too hard to write")) +class TestActivities(test_base.TestBase): + testcase_name = "activity API" + + def test_list(self): + """ + Upload three photos, and check that three corresponding activities + are created. + """ + self._delete_all() + self._create_test_photos(tag=False) + photos = self.client.photos.list() + + # Check that each activity is for a valid test photo + activities = self.client.activities.list() + self.assertEqual(len(activities), len(photos)) + for activity in activities: + self.assertIn(activity.data.id, [photo.id for photo in photos]) + + # Put the environment back the way we found it + for photo in photos: + photo.update(tags=self.TEST_TAG) + + def test_list_option(self): + """ + Check that the activity list options parameter works correctly + """ + self._delete_all() + self._create_test_photos(tag=False) + photos = self.client.photos.list() + + # Dummy photo update activity + photos[0].update(tags=photos[0].tags) + + # Check that the activities can be filtered + upload_activities = self.client.activities.list(options={"type": "photo-upload"}) + update_activities = self.client.activities.list(options={"type": "photo-update"}) + self.assertEqual(len(upload_activities), len(photos)) + self.assertEqual(len(update_activities), 1) + + # Put the environment back the way we found it + for photo in photos: + photo.update(tags=self.TEST_TAG) + + # The purge endpoint currently reports a 500: Internal Server Error + # PHP Fatal error: + # Call to undefined method DatabaseMySql::postActivitiesPurge() + # in /var/www/openphoto-master/src/libraries/models/Activity.php + # on line 66 + # Tracked in frontend/#1368 + @unittest.expectedFailure + def test_purge(self): + """ Test that the purge endpoint deletes all activities """ + activities = self.client.activities.list() + self.assertNotEqual(activities, []) + self.client.activities.purge() + activities = self.client.activities.list() + self.assertEqual(activities, []) + + def test_view(self): + """ Test that the view endpoint is working correctly """ + activity = self.client.activities.list()[0] + fields = activity.get_fields().copy() + + # Check that the view method returns the same data as the list + activity.view() + self.assertEqual(fields, activity.get_fields()) + + # Check using the Trovebox class + activity = self.client.activity.view(activity) + self.assertEqual(fields, activity.get_fields()) + + # Check passing the activity ID to the Trovebox class + activity = self.client.activity.view(activity.id) + self.assertEqual(fields, activity.get_fields()) diff --git a/tests/functional/test_albums.py b/tests/functional/test_albums.py index 2b1b21f..363c339 100644 --- a/tests/functional/test_albums.py +++ b/tests/functional/test_albums.py @@ -1,4 +1,10 @@ +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + from tests.functional import test_base +from trovebox.objects.album import Album class TestAlbums(test_base.TestBase): testcase_name = "album API" @@ -53,27 +59,50 @@ def test_update(self): self.albums = self.client.albums.list() self.assertEqual(self.albums[0].name, self.TEST_ALBUM) + @unittest.skipIf(test_base.get_test_server_api() == 1, + "update_cover was introduced in APIv2") + def test_update_cover(self): + """ Test that an album cover can be updated """ + self.albums[0].cover_update(self.photos[0]) + self.assertNotEqual(self.albums[0].cover.id, self.photos[1].id) + self.albums[0].cover_update(self.photos[1]) + self.assertEqual(self.albums[0].cover.id, self.photos[1].id) + + @unittest.skipIf(test_base.get_test_server_api() == 1, + "includeElements was introduced in APIv2") def test_view(self): """ Test the album view """ - album = self.albums[0] + # Do a view() with includeElements=False, using a fresh Album object + album = Album(self.client, {"id": self.albums[0].id}) + album.view() + # Make sure there are no photos reported + self.assertEqual(album.photos, None) - # Get the photos in the album using the Album object directly + # Get the photos with includeElements=True 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(NotImplementedError): - self.client.album.form(None) + def test_add_remove(self): + """ Test that photos can be added and removed from an album """ + # Make sure all photos are in the album + album = self.albums[0] + album.view(includeElements=True) + for photo in self.photos: + self.assertIn(photo.id, [p.id for p in album.photos]) + + # Remove two photos and check that they're gone + album.remove(self.photos[:2]) + album.view(includeElements=True) + self.assertEqual([p.id for p in album.photos], [self.photos[2].id]) - def test_add_photos(self): - """ If album.add_photos gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.client.album.add_photos(None, None) + # Add a photo and check that it's there + album.add(self.photos[1]) + album.view(includeElements=True) + self.assertNotIn(self.photos[0].id, [p.id for p in album.photos]) + self.assertIn(self.photos[1].id, [p.id for p in album.photos]) + self.assertIn(self.photos[2].id, [p.id for p in album.photos]) - def test_remove_photos(self): - """ If album.remove_photos gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.client.album.remove_photos(None, None) + # Put the environment back the way we found it + album.add(self.photos[0]) diff --git a/tests/functional/test_base.py b/tests/functional/test_base.py index 9c7056c..f7e4c25 100644 --- a/tests/functional/test_base.py +++ b/tests/functional/test_base.py @@ -42,8 +42,8 @@ def setUpClass(cls): else: print("\nTesting %s v%d" % (cls.testcase_name, cls.api_version)) - cls.client = trovebox.Trovebox(config_file=cls.config_file, - api_version=cls.api_version) + cls.client = trovebox.Trovebox(config_file=cls.config_file) + cls.client.configure(api_version=cls.api_version) if cls.client.photos.list() != []: raise ValueError("The test server (%s) contains photos. " @@ -124,7 +124,7 @@ def tearDown(self): logging.info("Finished %s\n", self.id()) @classmethod - def _create_test_photos(cls): + def _create_test_photos(cls, tag=True): """ Upload three test photos """ album = cls.client.album.create(cls.TEST_ALBUM) photos = [ @@ -139,8 +139,9 @@ def _create_test_photos(cls): albums=album.id), ] # Add the test tag, removing any autogenerated tags - for photo in photos: - photo.update(tags=cls.TEST_TAG) + if tag: + for photo in photos: + photo.update(tags=cls.TEST_TAG) @classmethod def _delete_all(cls): diff --git a/tests/functional/test_framework.py b/tests/functional/test_framework.py index 8495f0d..9444e4b 100644 --- a/tests/functional/test_framework.py +++ b/tests/functional/test_framework.py @@ -16,8 +16,8 @@ def test_api_version_zero(self): """ API v0 has a special hello world message """ - client = trovebox.Trovebox(config_file=self.config_file, - api_version=0) + client = trovebox.Trovebox(config_file=self.config_file) + client.configure(api_version=0) result = client.get("hello.json") self.assertEqual(result['message'], "Hello, world! This is version zero of the API!") @@ -28,8 +28,8 @@ def test_specified_api_version(self): For all API versions >0, we get a generic hello world message """ for api_version in range(1, test_base.get_test_server_api() + 1): - client = trovebox.Trovebox(config_file=self.config_file, - api_version=api_version) + client = trovebox.Trovebox(config_file=self.config_file) + client.configure(api_version=api_version) result = client.get("hello.json") self.assertEqual(result['message'], "Hello, world!") self.assertEqual(result['result']['__route__'], @@ -40,8 +40,7 @@ def test_unspecified_api_version(self): If the API version is unspecified, we get a generic hello world message. """ - client = trovebox.Trovebox(config_file=self.config_file, - api_version=None) + client = trovebox.Trovebox(config_file=self.config_file) result = client.get("hello.json") self.assertEqual(result['message'], "Hello, world!") self.assertEqual(result['result']['__route__'], "/hello.json") @@ -52,7 +51,7 @@ def test_future_api_version(self): (ValueError, since the returned 404 HTML page is not valid JSON) """ version = trovebox.LATEST_API_VERSION + 1 - client = trovebox.Trovebox(config_file=self.config_file, - api_version=version) + client = trovebox.Trovebox(config_file=self.config_file) + client.configure(api_version=version) with self.assertRaises(trovebox.Trovebox404Error): client.get("hello.json") diff --git a/tests/functional/test_photos.py b/tests/functional/test_photos.py index f153b49..599ce13 100644 --- a/tests/functional/test_photos.py +++ b/tests/functional/test_photos.py @@ -1,11 +1,39 @@ from __future__ import unicode_literals +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import requests import trovebox from tests.functional import test_base class TestPhotos(test_base.TestBase): testcase_name = "photo API" + def test_list_option(self): + """ + Check that the photo list options parameter works correctly + """ + option_tag = "Filter" + # Assign a photo with a new tag + self.photos[0].update(tagsAdd=option_tag) + + # Check that the photos can be filtered + photos = self.client.photos.list(options={"tags": option_tag}) + self.assertEqual(len(photos), 1) + self.assertEqual(photos[0].id, self.photos[0].id) + + # Put the environment back the way we found it + photos[0].update(tagsRemove=option_tag) + + # Photo share endpoint is currently not implemented + @unittest.expectedFailure + def test_share(self): + """ Test photo sharing (currently not implemented) """ + self.client.photos.share() + def test_delete_upload(self): """ Test photo deletion and upload """ # Delete one photo using the Trovebox class, passing in the id @@ -18,7 +46,7 @@ def test_delete_upload(self): # Check that they're gone self.assertEqual(self.client.photos.list(), []) - # Re-upload the photos, one of them using Bas64 encoding + # Re-upload the photos, one of them using Base64 encoding ret_val = self.client.photo.upload("tests/data/test_photo1.jpg", title=self.TEST_TITLE) self.client.photo.upload("tests/data/test_photo2.jpg", @@ -47,15 +75,22 @@ def test_delete_upload(self): self._delete_all() self._create_test_photos() - def test_edit(self): - """ Check that the edit request returns an HTML form """ - # Test using the Trovebox class - html = self.client.photo.edit(self.photos[0]) - self.assertIn(""}) - result = self.client.photo.edit(self.test_photos[0]) - mock_get.assert_called_with("/photo/1a/edit.json") - self.assertEqual(result, "
") +class TestPhotoReplace(TestPhotos): + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_replace(self, mock_post): + """Check that an existing photo can be replaced""" + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.replace(self.test_photos[1], + self.test_file, title="Test") + # It's not possible to compare the file object, + # so check each parameter individually + endpoint = mock_post.call_args[0] + title = mock_post.call_args[1]["title"] + files = mock_post.call_args[1]["files"] + self.assertEqual(endpoint, + ("/photo/%s/replace.json" % self.test_photos[1].id,)) + self.assertEqual(title, "Test") + self.assertIn("photo", files) + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) - @mock.patch.object(trovebox.Trovebox, 'get') - def test_photo_edit_id(self, mock_get): - """Check that a the photo edit endpoint is working when using an ID""" - mock_get.return_value = self._return_value({"markup": ""}) - result = self.client.photo.edit("1a") - mock_get.assert_called_with("/photo/1a/edit.json") - self.assertEqual(result, "") + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_replace_id(self, mock_post): + """Check that an existing photo can be replaced using its ID""" + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.replace(self.test_photos[1].id, + self.test_file, title="Test") + # It's not possible to compare the file object, + # so check each parameter individually + endpoint = mock_post.call_args[0] + title = mock_post.call_args[1]["title"] + files = mock_post.call_args[1]["files"] + self.assertEqual(endpoint, + ("/photo/%s/replace.json" % self.test_photos[1].id,)) + self.assertEqual(title, "Test") + self.assertIn("photo", files) + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) - @mock.patch.object(trovebox.Trovebox, 'get') - def test_photo_object_edit(self, mock_get): + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_object_replace(self, mock_post): """ - Check that a the photo edit endpoint is working - when using the photo object directly + Check that an existing photo can be replaced when using the + Photo object directly. """ - mock_get.return_value = self._return_value({"markup": ""}) - result = self.test_photos[0].edit() - mock_get.assert_called_with("/photo/1a/edit.json") - self.assertEqual(result, "") + photo_id = self.test_photos[1].id + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + self.test_photos[1].replace(self.test_file, title="Test") + # It's not possible to compare the file object, + # so check each parameter individually + endpoint = mock_post.call_args[0] + title = mock_post.call_args[1]["title"] + files = mock_post.call_args[1]["files"] + self.assertEqual(endpoint, ("/photo/%s/replace.json" % photo_id,)) + self.assertEqual(title, "Test") + self.assertIn("photo", files) + self.assertEqual(self.test_photos[1].get_fields(), + self.test_photos_dict[0]) -class TestPhotoReplace(TestPhotos): +class TestPhotoReplaceEncoded(TestPhotos): @mock.patch.object(trovebox.Trovebox, 'post') - def test_photo_replace(self, _): - """ If photo.replace gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.client.photo.replace(self.test_photos[0], self.test_file) + def test_photo_replace_encoded(self, mock_post): + """ + Check that a photo can be uploaded using Base64 encoding to + replace an existing photo. + """ + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.replace_encoded(self.test_photos[1], + self.test_file, title="Test") + with open(self.test_file, "rb") as in_file: + encoded_file = base64.b64encode(in_file.read()) + mock_post.assert_called_with("/photo/%s/replace.json" + % self.test_photos[1].id, + photo=encoded_file, title="Test") + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) @mock.patch.object(trovebox.Trovebox, 'post') - def test_photo_replace_id(self, _): - """ If photo.replace gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.client.photo.replace("1a", self.test_file) + def test_photo_replace_encoded_id(self, mock_post): + """ + Check that a photo can be uploaded using Base64 encoding to + replace an existing photo using its ID. + """ + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.replace_encoded(self.test_photos[1].id, + self.test_file, title="Test") + with open(self.test_file, "rb") as in_file: + encoded_file = base64.b64encode(in_file.read()) + mock_post.assert_called_with("/photo/%s/replace.json" + % self.test_photos[1].id, + photo=encoded_file, title="Test") + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) @mock.patch.object(trovebox.Trovebox, 'post') - def test_photo_object_replace(self, _): - """ If photo.replace gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.test_photos[0].replace(self.test_file) + def test_photo_object_replace_encoded(self, mock_post): + """ + Check that a photo can be uploaded using Base64 encoding to + replace an existing photo when using the Photo object directly. + """ + photo_id = self.test_photos[1].id + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + self.test_photos[1].replace_encoded(self.test_file, title="Test") + with open(self.test_file, "rb") as in_file: + encoded_file = base64.b64encode(in_file.read()) + mock_post.assert_called_with("/photo/%s/replace.json" + % photo_id, + photo=encoded_file, title="Test") + self.assertEqual(self.test_photos[1].get_fields(), + self.test_photos_dict[0]) +class TestPhotoReplaceFromUrl(TestPhotos): @mock.patch.object(trovebox.Trovebox, 'post') - def test_photo_replace_encoded(self, _): - """ If photo.replace_encoded gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.client.photo.replace_encoded(self.test_photos[0], - self.test_file) + def test_photo_replace_from_url(self, mock_post): + """ + Check that a photo can be imported from a url to + replace an existing photo. + """ + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.replace_from_url(self.test_photos[1], + "test_url", title="Test") + mock_post.assert_called_with("/photo/%s/replace.json" + % self.test_photos[1].id, + photo="test_url", title="Test") + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) @mock.patch.object(trovebox.Trovebox, 'post') - def test_photo_replace_encoded_id(self, _): - """ If photo.replace_encoded gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.client.photo.replace_encoded("1a", self.test_file) + def test_photo_id_replace_from_url(self, mock_post): + """ + Check that a photo can be imported from a url to + replace an existing photo using its ID. + """ + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.replace_from_url(self.test_photos[1].id, + "test_url", title="Test") + mock_post.assert_called_with("/photo/%s/replace.json" + % self.test_photos[1].id, + photo="test_url", title="Test") + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) @mock.patch.object(trovebox.Trovebox, 'post') - def test_photo_object_replace_encoded(self, _): - """ If photo.replace_encoded gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.test_photos[0].replace_encoded(photo_file=self.test_file) + def test_photo_object_replace_from_url(self, mock_post): + """ + Check that a photo can be imported from a url to + replace an existing photo when using the Photo object directly. + """ + photo_id = self.test_photos[1].id + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + self.test_photos[1].replace_from_url("test_url", title="Test") + mock_post.assert_called_with("/photo/%s/replace.json" + % photo_id, + photo="test_url", title="Test") + self.assertEqual(self.test_photos[1].get_fields(), + self.test_photos_dict[0]) class TestPhotoUpdate(TestPhotos): @mock.patch.object(trovebox.Trovebox, 'post') @@ -244,16 +362,30 @@ def test_photo_view(self, mock_get): """Check that a photo can be viewed""" mock_get.return_value = self._return_value(self.test_photos_dict[1]) result = self.client.photo.view(self.test_photos[0], + options={"foo": "bar", + "test1": "test2"}, returnSizes="20x20") - mock_get.assert_called_with("/photo/1a/view.json", returnSizes="20x20") + # Dict elemet can be in any order + self.assertIn(mock_get.call_args[0], + [("/photo/1a/foo-bar/test1-test2/view.json",), + ("/photo/1a/test1-test2/foo-bar/view.json",)]) + self.assertEqual(mock_get.call_args[1], {"returnSizes": "20x20"}) self.assertEqual(result.get_fields(), self.test_photos_dict[1]) @mock.patch.object(trovebox.Trovebox, 'get') def test_photo_view_id(self, mock_get): """Check that a photo can be viewed using its ID""" mock_get.return_value = self._return_value(self.test_photos_dict[1]) - result = self.client.photo.view("1a", returnSizes="20x20") - mock_get.assert_called_with("/photo/1a/view.json", returnSizes="20x20") + result = self.client.photo.view("1a", + options={"foo": "bar", + "test1": "test2"}, + returnSizes="20x20") + + # Dict elemet can be in any order + self.assertIn(mock_get.call_args[0], + [("/photo/1a/foo-bar/test1-test2/view.json",), + ("/photo/1a/test1-test2/foo-bar/view.json",)]) + self.assertEqual(mock_get.call_args[1], {"returnSizes": "20x20"}) self.assertEqual(result.get_fields(), self.test_photos_dict[1]) @mock.patch.object(trovebox.Trovebox, 'get') @@ -264,8 +396,14 @@ def test_photo_object_view(self, mock_get): """ mock_get.return_value = self._return_value(self.test_photos_dict[1]) photo = self.test_photos[0] - photo.view(returnSizes="20x20") - mock_get.assert_called_with("/photo/1a/view.json", returnSizes="20x20") + photo.view(returnSizes="20x20", options={"foo": "bar", + "test1": "test2"}) + + # Dict elemet can be in any order + self.assertIn(mock_get.call_args[0], + [("/photo/1a/foo-bar/test1-test2/view.json",), + ("/photo/1a/test1-test2/foo-bar/view.json",)]) + self.assertEqual(mock_get.call_args[1], {"returnSizes": "20x20"}) self.assertEqual(photo.get_fields(), self.test_photos_dict[1]) class TestPhotoUpload(TestPhotos): @@ -284,6 +422,7 @@ def test_photo_upload(self, mock_post): self.assertIn("photo", files) self.assertEqual(result.get_fields(), self.test_photos_dict[0]) +class TestPhotoUploadEncoded(TestPhotos): @mock.patch.object(trovebox.Trovebox, 'post') def test_photo_upload_encoded(self, mock_post): """Check that a photo can be uploaded using Base64 encoding""" @@ -295,24 +434,17 @@ def test_photo_upload_encoded(self, mock_post): photo=encoded_file, title="Test") self.assertEqual(result.get_fields(), self.test_photos_dict[0]) -class TestPhotoDynamicUrl(TestPhotos): - @mock.patch.object(trovebox.Trovebox, 'get') - def test_photo_dynamic_url(self, _): - """ If photo.dynamic_url gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.client.photo.dynamic_url(self.test_photos[0]) - - @mock.patch.object(trovebox.Trovebox, 'get') - def test_photo_dynamic_url_id(self, _): - """ If photo.dynamic_url gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.client.photo.dynamic_url("1a") - - @mock.patch.object(trovebox.Trovebox, 'get') - def test_photo_object_dynamic_url(self, _): - """ If photo.dynamic_url gets implemented, write a test! """ - with self.assertRaises(NotImplementedError): - self.test_photos[0].dynamic_url() +class TestPhotoUploadFromUrl(TestPhotos): + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_upload_from_url(self, mock_post): + """ + Check that a photo can be imported from a url. + """ + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.upload_from_url("test_url", title="Test") + mock_post.assert_called_with("/photo/upload.json", + photo="test_url", title="Test") + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) class TestPhotoNextPrevious(TestPhotos): @mock.patch.object(trovebox.Trovebox, 'get') @@ -321,8 +453,15 @@ def test_photo_next_previous(self, mock_get): mock_get.return_value = self._return_value( {"next": [self.test_photos_dict[0]], "previous": [self.test_photos_dict[1]]}) - result = self.client.photo.next_previous(self.test_photos[0]) - mock_get.assert_called_with("/photo/1a/nextprevious.json") + result = self.client.photo.next_previous(self.test_photos[0], + options={"foo": "bar", + "test1": "test2"}, + foo="bar") + # Dict elemet can be in any order + self.assertIn(mock_get.call_args[0], + [("/photo/1a/nextprevious/foo-bar/test1-test2.json",), + ("/photo/1a/nextprevious/test1-test2/foo-bar.json",)]) + self.assertEqual(mock_get.call_args[1], {"foo": "bar"}) self.assertEqual(result["next"][0].get_fields(), self.test_photos_dict[0]) self.assertEqual(result["previous"][0].get_fields(), @@ -337,8 +476,15 @@ def test_photo_next_previous_id(self, mock_get): mock_get.return_value = self._return_value( {"next": [self.test_photos_dict[0]], "previous": [self.test_photos_dict[1]]}) - result = self.client.photo.next_previous("1a") - mock_get.assert_called_with("/photo/1a/nextprevious.json") + result = self.client.photo.next_previous("1a", + options={"foo": "bar", + "test1": "test2"}, + foo="bar") + # Dict elemet can be in any order + self.assertIn(mock_get.call_args[0], + [("/photo/1a/nextprevious/foo-bar/test1-test2.json",), + ("/photo/1a/nextprevious/test1-test2/foo-bar.json",)]) + self.assertEqual(mock_get.call_args[1], {"foo": "bar"}) self.assertEqual(result["next"][0].get_fields(), self.test_photos_dict[0]) self.assertEqual(result["previous"][0].get_fields(), @@ -353,8 +499,14 @@ def test_photo_object_next_previous(self, mock_get): mock_get.return_value = self._return_value( {"next": [self.test_photos_dict[0]], "previous": [self.test_photos_dict[1]]}) - result = self.test_photos[0].next_previous() - mock_get.assert_called_with("/photo/1a/nextprevious.json") + result = self.test_photos[0].next_previous(options={"foo": "bar", + "test1": "test2"}, + foo="bar") + # Dict elemet can be in any order + self.assertIn(mock_get.call_args[0], + [("/photo/1a/nextprevious/foo-bar/test1-test2.json",), + ("/photo/1a/nextprevious/test1-test2/foo-bar.json",)]) + self.assertEqual(mock_get.call_args[1], {"foo": "bar"}) self.assertEqual(result["next"][0].get_fields(), self.test_photos_dict[0]) self.assertEqual(result["previous"][0].get_fields(), @@ -365,8 +517,10 @@ def test_photo_next(self, mock_get): """Check that the next photos are returned""" mock_get.return_value = self._return_value( {"next": [self.test_photos_dict[0]]}) - result = self.client.photo.next_previous(self.test_photos[0]) - mock_get.assert_called_with("/photo/1a/nextprevious.json") + result = self.client.photo.next_previous(self.test_photos[0], + foo="bar") + mock_get.assert_called_with("/photo/1a/nextprevious.json", + foo="bar") self.assertEqual(result["next"][0].get_fields(), self.test_photos_dict[0]) self.assertNotIn("previous", result) @@ -376,8 +530,10 @@ def test_photo_previous(self, mock_get): """Check that the previous photos are returned""" mock_get.return_value = self._return_value( {"previous": [self.test_photos_dict[1]]}) - result = self.client.photo.next_previous(self.test_photos[0]) - mock_get.assert_called_with("/photo/1a/nextprevious.json") + result = self.client.photo.next_previous(self.test_photos[0], + foo="bar") + mock_get.assert_called_with("/photo/1a/nextprevious.json", + foo="bar") self.assertEqual(result["previous"][0].get_fields(), self.test_photos_dict[1]) self.assertNotIn("next", result) @@ -388,8 +544,10 @@ def test_photo_multiple_next_previous(self, mock_get): mock_get.return_value = self._return_value( {"next": [self.test_photos_dict[0], self.test_photos_dict[0]], "previous": [self.test_photos_dict[1], self.test_photos_dict[1]]}) - result = self.client.photo.next_previous(self.test_photos[0]) - mock_get.assert_called_with("/photo/1a/nextprevious.json") + result = self.client.photo.next_previous(self.test_photos[0], + foo="bar") + mock_get.assert_called_with("/photo/1a/nextprevious.json", + foo="bar") self.assertEqual(result["next"][0].get_fields(), self.test_photos_dict[0]) self.assertEqual(result["next"][1].get_fields(), @@ -427,3 +585,96 @@ def test_photo_object_transform(self, mock_post): photo.transform(rotate="90") mock_post.assert_called_with("/photo/1a/transform.json", rotate="90") self.assertEqual(photo.get_fields(), self.test_photos_dict[1]) + +class TestPhotoObject(TestPhotos): + def test_photo_object_repr_without_id_or_name(self): + """ + Ensure the string representation on an object includes its class name + if the ID and Name attributes don't exist. + """ + photo = trovebox.objects.photo.Photo(self.client, {}) + self.assertEqual(repr(photo), "") + + def test_photo_object_repr_with_id(self): + """ Ensure the string representation on an object includes its id, if present """ + photo = trovebox.objects.photo.Photo(self.client, {"id": "Test ID"}) + self.assertEqual(repr(photo), "") + + def test_photo_object_repr_with_id_and_name(self): + """ Ensure the string representation on an object includes its name, if present """ + photo = trovebox.objects.photo.Photo(self.client, {"id": "Test ID", + "name": "Test Name"}) + self.assertEqual(repr(photo), "") + + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_object_create_attribute(self, _): + """ + Check that attributes are created when creating a + Photo object + """ + photo = trovebox.objects.photo.Photo(self.client, {"attribute": "test"}) + self.assertEqual(photo.attribute, "test") + + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_object_delete_attribute(self, _): + """ + Check that attributes are deleted when creating a + Photo object + """ + photo = trovebox.objects.photo.Photo(self.client, {"attribute": "test"}) + photo.delete() + with self.assertRaises(AttributeError): + value = photo.attribute + self.assertEqual(photo.get_fields(), {}) + + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_object_update_attribute(self, mock_post): + """ + Check that attributes are updated when creating a + Photo object + """ + photo = trovebox.objects.photo.Photo(self.client, {"attribute": "test"}) + mock_post.return_value = self._return_value({"attribute": "test2"}) + photo.update() + self.assertEqual(photo.attribute, "test2") + self.assertEqual(photo.get_fields(), {"attribute": "test2"}) + + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_object_create_illegal_attribute(self, _): + """ + Check that illegal attributes are ignored when creating a + Photo object + """ + photo = trovebox.objects.photo.Photo(self.client, {"_illegal_attribute": "test"}) + # The object's attribute shouldn't be created + with self.assertRaises(AttributeError): + value = photo._illegal_attribute + # The field dict gets created correctly, however. + self.assertEqual(photo.get_fields(), {"_illegal_attribute": "test"}) + + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_object_delete_illegal_attribute(self, _): + """ + Check that illegal attributes are ignored when deleting a + Photo object + """ + photo = trovebox.objects.photo.Photo(self.client, {"_illegal_attribute": "test"}) + photo.delete() + with self.assertRaises(AttributeError): + value = photo._illegal_attribute + self.assertEqual(photo.get_fields(), {}) + + @mock.patch.object(trovebox.Trovebox, 'post') + def test_photo_object_update_illegal_attribute(self, mock_post): + """ + Check that illegal attributes are ignored when updating a + Photo object + """ + photo = trovebox.objects.photo.Photo(self.client, {"_illegal_attribute": "test"}) + mock_post.return_value = self._return_value({"_illegal_attribute": "test2"}) + photo.update() + # The object's attribute shouldn't be created + with self.assertRaises(AttributeError): + value = photo._illegal_attribute + # The field dict gets updated correctly, however. + self.assertEqual(photo.get_fields(), {"_illegal_attribute": "test2"}) diff --git a/tests/unit/test_system.py b/tests/unit/test_system.py new file mode 100644 index 0000000..d505d2b --- /dev/null +++ b/tests/unit/test_system.py @@ -0,0 +1,66 @@ +from __future__ import unicode_literals +import json +import httpretty +from httpretty import GET + +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import trovebox + +class TestSystem(unittest.TestCase): + test_host = "test.example.com" + + def setUp(self): + self.client = trovebox.Trovebox(host=self.test_host) + + @staticmethod + def _return_value(result, message="", code=200): + return json.dumps({"message": message, "code": code, "result": result}) + +class TestSystemVersion(TestSystem): + test_result = {"api": "v2", + "database": "2.0.0"} + + @httpretty.activate + def test_version(self): + """Check that the version dictionary is returned correctly""" + httpretty.register_uri(GET, uri="http://test.example.com/system/version.json", + body=self._return_value(self.test_result), + status=200) + response = self.client.system.version() + + self.assertEqual(response, self.test_result) + +class TestSystemDiagnostics(TestSystem): + test_result = {'database': [{'label': 'failure', + 'message': 'Could not properly connect to the database.', + 'status': False}], + } + + @httpretty.activate + def test_diagnostics_pass(self): + """Check that the diagnostics dictionary is returned correctly on success""" + httpretty.register_uri(GET, uri="http://test.example.com/system/diagnostics.json", + body=self._return_value(self.test_result), + status=200) + response = self.client.system.diagnostics() + + self.assertEqual(response, self.test_result) + + @httpretty.activate + def test_diagnostics_fail(self): + """ + Check that the diagnostics dictionary is returned correctly on failure. + Although the JSON code is 500, no exception should be raised. + """ + # On failure, the diagnostics endpoint returns a JSON code of 500 + # and a response status code of 200. + httpretty.register_uri(GET, uri="http://test.example.com/system/diagnostics.json", + body=self._return_value(self.test_result, code=500), + status=200) + response = self.client.system.diagnostics() + + self.assertEqual(response, self.test_result) diff --git a/tests/unit/test_tags.py b/tests/unit/test_tags.py index c0248cd..61292d4 100644 --- a/tests/unit/test_tags.py +++ b/tests/unit/test_tags.py @@ -15,7 +15,7 @@ class TestTags(unittest.TestCase): def setUp(self): self.client = trovebox.Trovebox(host=self.test_host) - self.test_tags = [trovebox.objects.Tag(self.client, tag) + self.test_tags = [trovebox.objects.tag.Tag(self.client, tag) for tag in self.test_tags_dict] @staticmethod @@ -25,61 +25,70 @@ def _return_value(result, message="", code=200): class TestTagsList(TestTags): @mock.patch.object(trovebox.Trovebox, 'get') def test_tags_list(self, mock_get): - """Check that the the tag list is returned correctly""" + """Check that the tag list is returned correctly""" mock_get.return_value = self._return_value(self.test_tags_dict) - result = self.client.tags.list() - mock_get.assert_called_with("/tags/list.json") + result = self.client.tags.list(foo="bar") + mock_get.assert_called_with("/tags/list.json", foo="bar") self.assertEqual(len(result), 2) self.assertEqual(result[0].id, "tag1") self.assertEqual(result[0].count, 11) self.assertEqual(result[1].id, "tag2") self.assertEqual(result[1].count, 5) + @mock.patch.object(trovebox.Trovebox, 'get') + def test_empty_result(self, mock_get): + """Check that an empty result is transformed into an empty list """ + mock_get.return_value = self._return_value("") + result = self.client.tags.list(foo="bar") + mock_get.assert_called_with("/tags/list.json", foo="bar") + self.assertEqual(result, []) + + @mock.patch.object(trovebox.Trovebox, 'get') + def test_zero_rows(self, mock_get): + """Check that totalRows=0 is transformed into an empty list """ + mock_get.return_value = self._return_value([{"totalRows": 0}]) + result = self.client.tags.list(foo="bar") + mock_get.assert_called_with("/tags/list.json", foo="bar") + self.assertEqual(result, []) + +class TestTagCreate(TestTags): + @mock.patch.object(trovebox.Trovebox, 'post') + def test_tag_create(self, mock_post): + """Check that a tag can be created""" + mock_post.return_value = self._return_value(True) + result = self.client.tag.create("test", foo="bar") + mock_post.assert_called_with("/tag/create.json", tag="test", + foo="bar") + self.assertEqual(result, True) + class TestTagDelete(TestTags): @mock.patch.object(trovebox.Trovebox, 'post') def test_tag_delete(self, mock_post): """Check that a tag can be deleted""" mock_post.return_value = self._return_value(True) - result = self.client.tag.delete(self.test_tags[0]) - mock_post.assert_called_with("/tag/tag1/delete.json") + result = self.client.tag.delete(self.test_tags[0], foo="bar") + mock_post.assert_called_with("/tag/tag1/delete.json", foo="bar") self.assertEqual(result, True) @mock.patch.object(trovebox.Trovebox, 'post') def test_tag_delete_id(self, mock_post): """Check that a tag can be deleted using its ID""" mock_post.return_value = self._return_value(True) - result = self.client.tag.delete("tag1") - mock_post.assert_called_with("/tag/tag1/delete.json") + result = self.client.tag.delete("tag1", foo="bar") + mock_post.assert_called_with("/tag/tag1/delete.json", foo="bar") self.assertEqual(result, True) - @mock.patch.object(trovebox.Trovebox, 'post') - def test_tag_delete_failure(self, mock_post): - """Check that an exception is raised if a tag cannot be deleted""" - mock_post.return_value = self._return_value(False) - with self.assertRaises(trovebox.TroveboxError): - self.client.tag.delete(self.test_tags[0]) - @mock.patch.object(trovebox.Trovebox, 'post') def test_tag_object_delete(self, mock_post): """Check that a tag can be deleted when using the tag object directly""" mock_post.return_value = self._return_value(True) tag = self.test_tags[0] - result = tag.delete() - mock_post.assert_called_with("/tag/tag1/delete.json") + result = tag.delete(foo="bar") + mock_post.assert_called_with("/tag/tag1/delete.json", foo="bar") self.assertEqual(result, True) self.assertEqual(tag.get_fields(), {}) self.assertEqual(tag.id, None) - @mock.patch.object(trovebox.Trovebox, 'post') - def test_tag_object_delete_failure(self, mock_post): - """ - Check that an exception is raised if a tag cannot be deleted - when using the tag object directly - """ - mock_post.return_value = self._return_value(False) - with self.assertRaises(trovebox.TroveboxError): - self.test_tags[0].delete() - class TestTagUpdate(TestTags): @mock.patch.object(trovebox.Trovebox, 'post') def test_tag_update(self, mock_post): diff --git a/tox.ini b/tox.ini index b810cab..7961499 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27, py33 +envlist = py26, py27, py33, coverage [testenv] commands = python -m unittest discover tests/unit @@ -18,3 +18,12 @@ deps = ddt >= 0.3.0 unittest2 discover + +[testenv:coverage] +commands = coverage run --source trovebox setup.py test +deps = + mock >= 1.0.0 + # Hold httpretty at 0.6.5 until https://github.com/gabrielfalcao/HTTPretty/issues/114 is resolved + httpretty == 0.6.5 + ddt >= 0.3.0 + coverage diff --git a/trovebox/.pylint-disable.patch b/trovebox/.pylint-disable.patch new file mode 100644 index 0000000..5b96583 --- /dev/null +++ b/trovebox/.pylint-disable.patch @@ -0,0 +1,181 @@ +diff --unified --recursive '--exclude=.pylint-disable.patch' original/api/api_activity.py patched/api/api_activity.py +--- original/api/api_activity.py ++++ patched/api/api_activity.py +@@ -32,7 +32,7 @@ + """ + return self._client.post("/activities/purge.json", **kwds)["result"] + +-class ApiActivity(ApiBase): ++class ApiActivity(ApiBase): # pylint: disable=too-few-public-methods + """ Definitions of /activity/ API endpoints """ + def view(self, activity, **kwds): + """ +diff --unified --recursive '--exclude=.pylint-disable.patch' original/api/api_album.py patched/api/api_album.py +--- original/api/api_album.py ++++ patched/api/api_album.py +@@ -7,7 +7,7 @@ + from trovebox.objects.album import Album + from .api_base import ApiBase + +-class ApiAlbums(ApiBase): ++class ApiAlbums(ApiBase): # pylint: disable=too-few-public-methods + """ Definitions of /albums/ API endpoints """ + def list(self, **kwds): + """ +diff --unified --recursive '--exclude=.pylint-disable.patch' original/api/api_base.py patched/api/api_base.py +--- original/api/api_base.py ++++ patched/api/api_base.py +@@ -2,7 +2,7 @@ + api_base.py: Base class for all API classes + """ + +-class ApiBase(object): ++class ApiBase(object): # pylint: disable=too-few-public-methods + """ Base class for all API objects """ + def __init__(self, client): + self._client = client +diff --unified --recursive '--exclude=.pylint-disable.patch' original/api/api_tag.py patched/api/api_tag.py +--- original/api/api_tag.py ++++ patched/api/api_tag.py +@@ -2,14 +2,14 @@ + api_tag.py : Trovebox Tag API Classes + """ + try: +- from urllib.parse import quote # Python3 ++ from urllib.parse import quote # Python3 # pylint: disable=import-error,no-name-in-module + except ImportError: + from urllib import quote # Python2 + + from trovebox.objects.tag import Tag + from .api_base import ApiBase + +-class ApiTags(ApiBase): ++class ApiTags(ApiBase): # pylint: disable=too-few-public-methods + """ Definitions of /tags/ API endpoints """ + def list(self, **kwds): + """ +diff --unified --recursive '--exclude=.pylint-disable.patch' original/auth.py patched/auth.py +--- original/auth.py ++++ patched/auth.py +@@ -4,7 +4,7 @@ + from __future__ import unicode_literals + import os + try: +- from configparser import ConfigParser # Python3 ++ from configparser import ConfigParser # Python3 # pylint: disable=import-error + except ImportError: + from ConfigParser import SafeConfigParser as ConfigParser # Python2 + try: +@@ -12,9 +12,9 @@ + except ImportError: # pragma: no cover + import StringIO as io # Python2 + +-class Auth(object): ++class Auth(object): # pylint: disable=too-few-public-methods + """OAuth secrets""" +- def __init__(self, config_file, host, ++ def __init__(self, config_file, host, # pylint: disable=too-many-arguments + consumer_key, consumer_secret, + token, token_secret): + if host is None: +@@ -69,7 +69,7 @@ + parser = ConfigParser() + parser.optionxform = str # Case-sensitive options + try: +- parser.read_file(buf) # Python3 ++ parser.read_file(buf) # Python3 # pylint: disable=maybe-no-member + except AttributeError: + parser.readfp(buf) # Python2 + +diff --unified --recursive '--exclude=.pylint-disable.patch' original/http.py patched/http.py +--- original/http.py ++++ patched/http.py +@@ -7,7 +7,7 @@ + import requests_oauthlib + import logging + try: +- from urllib.parse import urlparse, urlunparse # Python3 ++ from urllib.parse import urlparse, urlunparse # Python3 # pylint: disable=import-error,no-name-in-module + except ImportError: + from urlparse import urlparse, urlunparse # Python2 + +@@ -16,9 +16,9 @@ + from .auth import Auth + + if sys.version < '3': +- TEXT_TYPE = unicode ++ TEXT_TYPE = unicode # pylint: disable=invalid-name + else: # pragma: no cover +- TEXT_TYPE = str ++ TEXT_TYPE = str # pylint: disable=invalid-name + + DUPLICATE_RESPONSE = {"code": 409, + "message": "This photo already exists"} +@@ -37,7 +37,7 @@ + "ssl_verify" : True, + } + +- def __init__(self, config_file=None, host=None, ++ def __init__(self, config_file=None, host=None, # pylint: disable=too-many-arguments + consumer_key='', consumer_secret='', + token='', token_secret='', api_version=None): + +diff --unified --recursive '--exclude=.pylint-disable.patch' original/__init__.py patched/__init__.py +--- original/__init__.py ++++ patched/__init__.py +@@ -13,7 +13,7 @@ + + LATEST_API_VERSION = 2 + +-class Trovebox(Http): ++class Trovebox(Http): # pylint: disable=too-many-instance-attributes + """ + Client library for Trovebox + If no parameters are specified, config is loaded from the default +@@ -25,7 +25,7 @@ + This should be used to ensure that your application will continue to work + even if the Trovebox API is updated to a new revision. + """ +- def __init__(self, config_file=None, host=None, ++ def __init__(self, config_file=None, host=None, # pylint: disable=too-many-arguments + consumer_key='', consumer_secret='', + token='', token_secret='', + api_version=None): +diff --unified --recursive '--exclude=.pylint-disable.patch' original/main.py patched/main.py +--- original/main.py ++++ patched/main.py +@@ -26,7 +26,7 @@ + + ################################################################# + +-def main(args=sys.argv[1:]): ++def main(args=sys.argv[1:]): # pylint: disable=too-many-branches + """Run the commandline script""" + usage = "%prog --help" + parser = OptionParser(usage, add_help_option=False) +@@ -85,11 +85,11 @@ + sys.exit(1) + + if options.method == "GET": +- result = client.get(options.endpoint, process_response=False, ++ result = client.get(options.endpoint, process_response=False, # pylint: disable=star-args + **params) + else: + params, files = extract_files(params) +- result = client.post(options.endpoint, process_response=False, ++ result = client.post(options.endpoint, process_response=False, # pylint: disable=star-args + files=files, **params) + for file_ in files: + files[file_].close() +diff --unified --recursive '--exclude=.pylint-disable.patch' original/objects/trovebox_object.py patched/objects/trovebox_object.py +--- original/objects/trovebox_object.py ++++ patched/objects/trovebox_object.py +@@ -5,7 +5,7 @@ + """ Base object supporting the storage of custom fields as attributes """ + _type = "None" + def __init__(self, client, json_dict): +- self.id = None ++ self.id = None # pylint: disable=invalid-name + self.name = None + self._client = client + self._json_dict = json_dict diff --git a/trovebox/.pylint-ignores.patch b/trovebox/.pylint-ignores.patch deleted file mode 100644 index 41fca5e..0000000 --- a/trovebox/.pylint-ignores.patch +++ /dev/null @@ -1,238 +0,0 @@ -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/api_album.py patched/api_album.py ---- original/api_album.py 2013-08-16 18:12:30.434212000 +0100 -+++ patched/api_album.py 2013-08-16 18:13:29.678506001 +0100 -@@ -3,7 +3,7 @@ - """ - from .objects import Album - --class ApiAlbums(object): -+class ApiAlbums(object): # pylint: disable=R0903,C0111 - def __init__(self, client): - self._client = client - -@@ -12,7 +12,7 @@ - results = self._client.get("/albums/list.json", **kwds)["result"] - return [Album(self._client, album) for album in results] - --class ApiAlbum(object): -+class ApiAlbum(object): # pylint: disable=C0111 - def __init__(self, client): - self._client = client - -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/api_photo.py patched/api_photo.py ---- original/api_photo.py 2013-08-16 18:12:30.434212000 +0100 -+++ patched/api_photo.py 2013-08-16 18:13:29.678506001 +0100 -@@ -20,7 +20,7 @@ - ids.append(photo) - return ids - --class ApiPhotos(object): -+class ApiPhotos(object): # pylint: disable=C0111 - def __init__(self, client): - self._client = client - -@@ -54,7 +54,7 @@ - raise TroveboxError("Delete response returned False") - return True - --class ApiPhoto(object): -+class ApiPhoto(object): # pylint: disable=C0111 - def __init__(self, client): - self._client = client - -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/api_tag.py patched/api_tag.py ---- original/api_tag.py 2013-08-16 18:12:30.434212000 +0100 -+++ patched/api_tag.py 2013-08-16 18:13:29.678506001 +0100 -@@ -3,7 +3,7 @@ - """ - from .objects import Tag - --class ApiTags(object): -+class ApiTags(object): # pylint: disable=R0903,C0111 - def __init__(self, client): - self._client = client - -@@ -12,7 +12,7 @@ - results = self._client.get("/tags/list.json", **kwds)["result"] - return [Tag(self._client, tag) for tag in results] - --class ApiTag(object): -+class ApiTag(object): # pylint: disable=C0111 - def __init__(self, client): - self._client = client - -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/auth.py patched/auth.py ---- original/auth.py 2013-08-16 18:13:24.966482000 +0100 -+++ patched/auth.py 2013-08-16 18:13:51.766615537 +0100 -@@ -4,7 +4,7 @@ - from __future__ import unicode_literals - import os - try: -- from configparser import ConfigParser # Python3 -+ from configparser import ConfigParser # Python3 # pylint: disable=F0401 - except ImportError: - from ConfigParser import SafeConfigParser as ConfigParser # Python2 - try: -@@ -12,9 +12,9 @@ - except ImportError: - import StringIO as io # Python2 - --class Auth(object): -+class Auth(object): # pylint: disable=R0903 - """OAuth secrets""" -- def __init__(self, config_file, host, -+ def __init__(self, config_file, host, # pylint: disable=R0913 - consumer_key, consumer_secret, - token, token_secret): - if host is None: -@@ -69,7 +69,7 @@ - parser = ConfigParser() - parser.optionxform = str # Case-sensitive options - try: -- parser.read_file(buf) # Python3 -+ parser.read_file(buf) # Python3 # pylint: disable=E1103 - except AttributeError: - parser.readfp(buf) # Python2 - -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/http.py patched/http.py ---- original/http.py 2013-08-16 17:54:30.688858000 +0100 -+++ patched/http.py 2013-08-16 18:14:14.106726301 +0100 -@@ -7,18 +7,18 @@ - import requests_oauthlib - import logging - try: -- from urllib.parse import urlparse, urlunparse # Python3 -+ from urllib.parse import urlparse, urlunparse # Python3 # pylint: disable=F0401,E0611 - except ImportError: - from urlparse import urlparse, urlunparse # Python2 - - from .objects import TroveboxObject --from .errors import * -+from .errors import * # pylint: disable=W0401 - from .auth import Auth - - if sys.version < '3': -- TEXT_TYPE = unicode -+ TEXT_TYPE = unicode # pylint: disable=C0103 - else: -- TEXT_TYPE = str -+ TEXT_TYPE = str # pylint: disable=C0103 - - DUPLICATE_RESPONSE = {"code": 409, - "message": "This photo already exists"} -@@ -37,7 +37,7 @@ - "ssl_verify" : True, - } - -- def __init__(self, config_file=None, host=None, -+ def __init__(self, config_file=None, host=None, # pylint: disable=R0913 - consumer_key='', consumer_secret='', - token='', token_secret='', api_version=None): - -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/__init__.py patched/__init__.py ---- original/__init__.py 2013-08-16 18:12:30.438212000 +0100 -+++ patched/__init__.py 2013-08-16 18:13:29.678506001 +0100 -@@ -2,7 +2,7 @@ - __init__.py : Trovebox package top level - """ - from .http import Http --from .errors import * -+from .errors import * # pylint: disable=W0401 - from ._version import __version__ - from . import api_photo - from . import api_tag -@@ -22,7 +22,7 @@ - This should be used to ensure that your application will continue to work - even if the Trovebox API is updated to a new revision. - """ -- def __init__(self, config_file=None, host=None, -+ def __init__(self, config_file=None, host=None, # pylint: disable=R0913 - consumer_key='', consumer_secret='', - token='', token_secret='', - api_version=None): -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/main.py patched/main.py ---- original/main.py 2013-08-16 18:12:30.438212000 +0100 -+++ patched/main.py 2013-08-16 18:13:29.678506001 +0100 -@@ -26,7 +26,7 @@ - - ################################################################# - --def main(args=sys.argv[1:]): -+def main(args=sys.argv[1:]): # pylint: disable=R0912,C0111 - usage = "%prog --help" - parser = OptionParser(usage, add_help_option=False) - parser.add_option('-c', '--config', help="Configuration file to use", -@@ -84,13 +84,13 @@ - sys.exit(1) - - if options.method == "GET": -- result = client.get(options.endpoint, process_response=False, -+ result = client.get(options.endpoint, process_response=False, # pylint: disable=W0142 - **params) - else: - params, files = extract_files(params) -- result = client.post(options.endpoint, process_response=False, -+ result = client.post(options.endpoint, process_response=False, # pylint: disable=W0142 - files=files, **params) -- for f in files: -+ for f in files: # pylint: disable=C0103 - files[f].close() - - if options.verbose: -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/objects.py patched/objects.py ---- original/objects.py 2013-08-16 18:12:30.438212000 +0100 -+++ patched/objects.py 2013-08-16 18:13:29.682506021 +0100 -@@ -2,16 +2,16 @@ - objects.py : Basic Trovebox API Objects - """ - try: -- from urllib.parse import quote # Python3 -+ from urllib.parse import quote # Python3 # pylint: disable=F0401,E0611 - except ImportError: - from urllib import quote # Python2 - - from .errors import TroveboxError - --class TroveboxObject(object): -+class TroveboxObject(object): # pylint: disable=R0903 - """ Base object supporting the storage of custom fields as attributes """ - def __init__(self, trovebox, json_dict): -- self.id = None -+ self.id = None # pylint: disable=C0103 - self.name = None - self._trovebox = trovebox - self._json_dict = json_dict -@@ -57,7 +57,7 @@ - return self._json_dict - - --class Photo(TroveboxObject): -+class Photo(TroveboxObject): # pylint: disable=C0111 - def delete(self, **kwds): - """ - Delete this photo. -@@ -147,7 +147,7 @@ - - self._replace_fields(new_dict) - --class Tag(TroveboxObject): -+class Tag(TroveboxObject): # pylint: disable=C0111 - def delete(self, **kwds): - """ - Delete this tag. -@@ -168,7 +168,7 @@ - self._replace_fields(new_dict) - - --class Album(TroveboxObject): -+class Album(TroveboxObject): # pylint: disable=C0111 - def __init__(self, trovebox, json_dict): - self.photos = None - self.cover = None -diff --unified --recursive '--exclude=.pylint-ignores.patch' original/_version.py patched/_version.py ---- original/_version.py 2013-08-16 18:12:30.438212000 +0100 -+++ patched/_version.py 2013-08-16 18:13:29.682506021 +0100 -@@ -1,2 +1,2 @@ -- -+ # pylint: disable=C0111 - __version__ = "0.4" diff --git a/trovebox/__init__.py b/trovebox/__init__.py index f8b0a28..e49df85 100644 --- a/trovebox/__init__.py +++ b/trovebox/__init__.py @@ -2,11 +2,14 @@ __init__.py : Trovebox package top level """ from .http import Http -from .errors import * +from .errors import TroveboxError, TroveboxDuplicateError, Trovebox404Error from ._version import __version__ -from . import api_photo -from . import api_tag -from . import api_album +from trovebox.api import api_photo +from trovebox.api import api_tag +from trovebox.api import api_album +from trovebox.api import api_action +from trovebox.api import api_activity +from trovebox.api import api_system LATEST_API_VERSION = 2 @@ -36,3 +39,7 @@ def __init__(self, config_file=None, host=None, self.tag = api_tag.ApiTag(self) self.albums = api_album.ApiAlbums(self) self.album = api_album.ApiAlbum(self) + self.action = api_action.ApiAction(self) + self.activities = api_activity.ApiActivities(self) + self.activity = api_activity.ApiActivity(self) + self.system = api_system.ApiSystem(self) diff --git a/trovebox/_version.py b/trovebox/_version.py index 53e1c09..0a7c318 100644 --- a/trovebox/_version.py +++ b/trovebox/_version.py @@ -1,2 +1,2 @@ - -__version__ = "0.5.1" +"""Current version string""" +__version__ = "0.6" diff --git a/trovebox/api/__init__.py b/trovebox/api/__init__.py new file mode 100644 index 0000000..5dd3c68 --- /dev/null +++ b/trovebox/api/__init__.py @@ -0,0 +1,4 @@ +""" +trovebox.api Package + Definitions for each of the Trovebox API endpoints +""" diff --git a/trovebox/api/api_action.py b/trovebox/api/api_action.py new file mode 100644 index 0000000..bf6a7e3 --- /dev/null +++ b/trovebox/api/api_action.py @@ -0,0 +1,55 @@ +""" +api_action.py : Trovebox Action API Classes +""" +from trovebox.objects.action import Action +from .api_base import ApiBase + +class ApiAction(ApiBase): + """ Definitions of /action/ API endpoints """ + def create(self, target, target_type=None, **kwds): + """ + Endpoint: /action///create.json + + Creates a new action and returns it. + The target parameter can either be an id or a Trovebox object. + If a Trovebox object is used, the target type is inferred + automatically. + """ + # Extract the target type + if target_type is None: + target_type = target.get_type() + + # Extract the target ID + try: + target_id = target.id + except AttributeError: + target_id = target + + result = self._client.post("/action/%s/%s/create.json" % + (target_id, target_type), + **kwds)["result"] + return Action(self._client, result) + + def delete(self, action, **kwds): + """ + Endpoint: /action//delete.json + + Deletes an action. + Returns True if successful. + Raises a TroveboxError if not. + """ + return self._client.post("/action/%s/delete.json" % + self._extract_id(action), + **kwds)["result"] + + def view(self, action, **kwds): + """ + Endpoint: /action//view.json + + Requests all properties of an action. + Returns the requested action object. + """ + result = self._client.get("/action/%s/view.json" % + self._extract_id(action), + **kwds)["result"] + return Action(self._client, result) diff --git a/trovebox/api/api_activity.py b/trovebox/api/api_activity.py new file mode 100644 index 0000000..2e17c88 --- /dev/null +++ b/trovebox/api/api_activity.py @@ -0,0 +1,51 @@ +""" +api_activity.py : Trovebox Activity API Classes +""" +import json +from trovebox.objects.activity import Activity +from .api_base import ApiBase + +class ApiActivities(ApiBase): + """ Definitions of /activities/ API endpoints """ + def list(self, options=None, **kwds): + """ + Endpoint: /activities[/]/list.json + + Returns a list of Activity objects. + The options parameter can be used to narrow down the activities. + Eg: options={"type": "photo-upload"} + """ + option_string = self._build_option_string(options) + activities = self._client.get("/activities%s/list.json" % option_string, + **kwds)["result"] + activities = self._result_to_list(activities) + return [Activity(self._client, activity) for activity in activities] + + def purge(self, **kwds): + """ + Endpoint: /activities/purge.json + + Purges all activities. + Returns True if successful. + Raises a TroveboxError if not. + Currently not working due to frontend issue #1368. + """ + return self._client.post("/activities/purge.json", **kwds)["result"] + +class ApiActivity(ApiBase): + """ Definitions of /activity/ API endpoints """ + def view(self, activity, **kwds): + """ + Endpoint: /activity//view.json + + Requests all properties of an activity. + Returns the requested activity object. + """ + result = self._client.get("/activity/%s/view.json" % + self._extract_id(activity), + **kwds)["result"] + + # TBD: Why is the result enclosed/encoded like this? + result = result["0"] + result["data"] = json.loads(result["data"]) + return Activity(self._client, result) diff --git a/trovebox/api/api_album.py b/trovebox/api/api_album.py new file mode 100644 index 0000000..216b60b --- /dev/null +++ b/trovebox/api/api_album.py @@ -0,0 +1,152 @@ +""" +api_album.py : Trovebox Album API Classes +""" +import collections + +from trovebox.objects.trovebox_object import TroveboxObject +from trovebox.objects.album import Album +from .api_base import ApiBase + +class ApiAlbums(ApiBase): + """ Definitions of /albums/ API endpoints """ + def list(self, **kwds): + """ + Endpoint: /albums/list.json + + Returns a list of Album objects. + """ + albums = self._client.get("/albums/list.json", **kwds)["result"] + albums = self._result_to_list(albums) + return [Album(self._client, album) for album in albums] + +class ApiAlbum(ApiBase): + """ Definitions of /album/ API endpoints """ + def cover_update(self, album, photo, **kwds): + """ + Endpoint: /album//cover//update.json + + Update the cover photo of an album. + Returns the updated album object. + """ + result = self._client.post("/album/%s/cover/%s/update.json" % + (self._extract_id(album), + self._extract_id(photo)), + **kwds)["result"] + + # API currently doesn't return the updated album + # (frontend issue #1369) + if isinstance(result, bool): # pragma: no cover + result = self._client.get("/album/%s/view.json" % + self._extract_id(album))["result"] + + return Album(self._client, result) + + def create(self, name, **kwds): + """ + Endpoint: /album/create.json + + Creates a new album and returns it. + """ + result = self._client.post("/album/create.json", + name=name, **kwds)["result"] + return Album(self._client, result) + + def delete(self, album, **kwds): + """ + Endpoint: /album//delete.json + + Deletes an album. + Returns True if successful. + Raises a TroveboxError if not. + """ + return self._client.post("/album/%s/delete.json" % + self._extract_id(album), + **kwds)["result"] + + def add(self, album, objects, object_type=None, **kwds): + """ + Endpoint: /album///add.json + + Add objects (eg. Photos) to an album. + The objects are a list of either IDs or Trovebox objects. + If Trovebox objects are used, the object type is inferred + automatically. + Returns the updated album object. + """ + return self._add_remove("add", album, objects, object_type, + **kwds) + + def remove(self, album, objects, object_type=None, **kwds): + """ + Endpoint: /album///remove.json + + Remove objects (eg. Photos) to an album. + The objects are a list of either IDs or Trovebox objects. + If Trovebox objects are used, the object type is inferred + automatically. + Returns the updated album object. + """ + return self._add_remove("remove", album, objects, object_type, + **kwds) + + def _add_remove(self, action, album, objects, object_type=None, + **kwds): + """Common code for the add and remove endpoints.""" + # Ensure we have an iterable of objects + if not isinstance(objects, collections.Iterable): + objects = [objects] + + # Extract the type of the objects + if object_type is None: + object_type = objects[0].get_type() + + for i, obj in enumerate(objects): + if isinstance(obj, TroveboxObject): + # Ensure all objects are the same type + if obj.get_type() != object_type: + raise ValueError("Not all objects are of type '%s'" + % object_type) + # Extract the ids of the objects + objects[i] = obj.id + + result = self._client.post("/album/%s/%s/%s.json" % + (self._extract_id(album), + object_type, action), + ids=objects, **kwds)["result"] + + # API currently doesn't return the updated album + # (frontend issue #1369) + if isinstance(result, bool): # pragma: no cover + result = self._client.get("/album/%s/view.json" % + self._extract_id(album))["result"] + return Album(self._client, result) + + def update(self, album, **kwds): + """ + Endpoint: /album//update.json + + Updates an album with the specified parameters. + Returns the updated album object. + """ + result = self._client.post("/album/%s/update.json" % + self._extract_id(album), + **kwds)["result"] + + # APIv1 doesn't return the updated album (frontend issue #937) + if isinstance(result, bool): # pragma: no cover + result = self._client.get("/album/%s/view.json" % + self._extract_id(album))["result"] + + return Album(self._client, result) + + def view(self, album, **kwds): + """ + Endpoint: /album//view.json + + Requests all properties of an album. + Returns the requested album object. + """ + result = self._client.get("/album/%s/view.json" % + self._extract_id(album), + **kwds)["result"] + return Album(self._client, result) diff --git a/trovebox/api/api_base.py b/trovebox/api/api_base.py new file mode 100644 index 0000000..5aaf16c --- /dev/null +++ b/trovebox/api/api_base.py @@ -0,0 +1,38 @@ +""" +api_base.py: Base class for all API classes +""" + +class ApiBase(object): + """ Base class for all API objects """ + def __init__(self, client): + self._client = client + + @staticmethod + def _build_option_string(options): + """ + :param options: dictionary containing the options + :returns: option_string formatted for an API endpoint + """ + option_string = "" + if options is not None: + for key in options: + option_string += "/%s-%s" % (key, options[key]) + return option_string + + @staticmethod + def _extract_id(obj): + """ Return obj.id, or obj if the object doesn't have an ID """ + try: + return obj.id + except AttributeError: + return obj + + @staticmethod + def _result_to_list(result): + """ Handle the case where the result contains no items """ + if not result: + return [] + if "totalRows" in result[0] and result[0]["totalRows"] == 0: + return [] + else: + return result diff --git a/trovebox/api/api_photo.py b/trovebox/api/api_photo.py new file mode 100644 index 0000000..dfc98ff --- /dev/null +++ b/trovebox/api/api_photo.py @@ -0,0 +1,241 @@ +""" +api_photo.py : Trovebox Photo API Classes +""" +import base64 + +from trovebox.objects.photo import Photo +from .api_base import ApiBase + +class ApiPhotos(ApiBase): + """ Definitions of /photos/ API endpoints """ + def list(self, options=None, **kwds): + """ + Endpoint: /photos[/]/list.json + + Returns a list of Photo objects. + The options parameter can be used to narrow down the list. + Eg: options={"album": } + """ + option_string = self._build_option_string(options) + photos = self._client.get("/photos%s/list.json" % option_string, + **kwds)["result"] + photos = self._result_to_list(photos) + return [Photo(self._client, photo) for photo in photos] + + def share(self, options=None, **kwds): + """ + Endpoint: /photos[//share.json + + Not currently implemented. + """ + option_string = self._build_option_string(options) + return self._client.post("/photos%s/share.json" % option_string, + **kwds)["result"] + + def delete(self, photos, **kwds): + """ + Endpoint: /photos/delete.json + + Deletes a list of photos. + Returns True if successful. + Raises a TroveboxError if not. + """ + ids = [self._extract_id(photo) for photo in photos] + return self._client.post("/photos/delete.json", ids=ids, + **kwds)["result"] + + def update(self, photos, **kwds): + """ + Endpoint: /photos//update.json + + Updates a list of photos with the specified parameters. + Returns True if successful. + Raises TroveboxError if not. + """ + ids = [self._extract_id(photo) for photo in photos] + return self._client.post("/photos/update.json", ids=ids, + **kwds)["result"] + +class ApiPhoto(ApiBase): + """ Definitions of /photo/ API endpoints """ + def delete(self, photo, **kwds): + """ + Endpoint: /photo//delete.json + + Deletes a photo. + Returns True if successful. + Raises a TroveboxError if not. + """ + return self._client.post("/photo/%s/delete.json" % + self._extract_id(photo), + **kwds)["result"] + + def delete_source(self, photo, **kwds): + """ + Endpoint: /photo//source/delete.json + + Delete the source files of a photo. + Returns True if successful. + Raises a TroveboxError if not. + """ + return self._client.post("/photo/%s/source/delete.json" % + self._extract_id(photo), + **kwds)["result"] + + def replace(self, photo, photo_file, **kwds): + """ + Endpoint: /photo//replace.json + + Uploads the specified photo file to replace an existing photo. + """ + with open(photo_file, 'rb') as in_file: + result = self._client.post("/photo/%s/replace.json" % + self._extract_id(photo), + files={'photo': in_file}, + **kwds)["result"] + return Photo(self._client, result) + + def replace_encoded(self, photo, photo_file, **kwds): + """ + Endpoint: /photo//replace.json + + Base64-encodes and uploads the specified photo filename to + replace an existing photo. + """ + with open(photo_file, "rb") as in_file: + encoded_photo = base64.b64encode(in_file.read()) + result = self._client.post("/photo/%s/replace.json" % + self._extract_id(photo), + photo=encoded_photo, + **kwds)["result"] + return Photo(self._client, result) + + def replace_from_url(self, photo, url, **kwds): + """ + Endpoint: /photo/replace.json + + Import a photo from the specified URL to replace an existing + photo. + """ + result = self._client.post("/photo/%s/replace.json" % + self._extract_id(photo), + photo=url, + **kwds)["result"] + return Photo(self._client, result) + + + def update(self, photo, **kwds): + """ + Endpoint: /photo//update.json + + Updates a photo with the specified parameters. + Returns the updated photo object. + """ + result = self._client.post("/photo/%s/update.json" % + self._extract_id(photo), + **kwds)["result"] + return Photo(self._client, result) + + def view(self, photo, options=None, **kwds): + """ + Endpoint: /photo/[/]/view.json + + Requests all properties of a photo. + Can be used to obtain URLs for the photo at a particular size, + by using the "returnSizes" parameter. + Returns the requested photo object. + The options parameter can be used to pass in additional options. + Eg: options={"token": } + """ + option_string = self._build_option_string(options) + result = self._client.get("/photo/%s%s/view.json" % + (self._extract_id(photo), option_string), + **kwds)["result"] + return Photo(self._client, result) + + def upload(self, photo_file, **kwds): + """ + Endpoint: /photo/upload.json + + Uploads the specified photo filename. + """ + with open(photo_file, 'rb') as in_file: + result = self._client.post("/photo/upload.json", + files={'photo': in_file}, + **kwds)["result"] + return Photo(self._client, result) + + def upload_encoded(self, photo_file, **kwds): + """ + Endpoint: /photo/upload.json + + Base64-encodes and uploads the specified photo filename. + """ + with open(photo_file, "rb") as in_file: + encoded_photo = base64.b64encode(in_file.read()) + result = self._client.post("/photo/upload.json", photo=encoded_photo, + **kwds)["result"] + return Photo(self._client, result) + + def upload_from_url(self, url, **kwds): + """ + Endpoint: /photo/upload.json + + Import a photo from the specified URL + """ + result = self._client.post("/photo/upload.json", photo=url, + **kwds)["result"] + return Photo(self._client, result) + + def next_previous(self, photo, options=None, **kwds): + """ + Endpoint: /photo//nextprevious[/].json + + Returns a dict containing the next and previous photo lists + (there may be more than one next/previous photo returned). + The options parameter can be used to narrow down the photos + Eg: options={"album": } + """ + option_string = self._build_option_string(options) + result = self._client.get("/photo/%s/nextprevious%s.json" % + (self._extract_id(photo), option_string), + **kwds)["result"] + value = {} + if "next" in result: + # Workaround for APIv1 + if not isinstance(result["next"], list): # pragma: no cover + result["next"] = [result["next"]] + + value["next"] = [] + for photo in result["next"]: + value["next"].append(Photo(self._client, photo)) + + if "previous" in result: + # Workaround for APIv1 + if not isinstance(result["previous"], list): # pragma: no cover + result["previous"] = [result["previous"]] + + value["previous"] = [] + for photo in result["previous"]: + value["previous"].append(Photo(self._client, photo)) + + return value + + def transform(self, photo, **kwds): + """ + Endpoint: /photo//transform.json + + Performs the specified transformations. + eg. transform(photo, rotate=90) + Returns the transformed photo. + """ + result = self._client.post("/photo/%s/transform.json" % + self._extract_id(photo), + **kwds)["result"] + + # APIv1 doesn't return the transformed photo (frontend issue #955) + if isinstance(result, bool): # pragma: no cover + result = self._client.get("/photo/%s/view.json" % + self._extract_id(photo))["result"] + + return Photo(self._client, result) diff --git a/trovebox/api/api_system.py b/trovebox/api/api_system.py new file mode 100644 index 0000000..eef087d --- /dev/null +++ b/trovebox/api/api_system.py @@ -0,0 +1,27 @@ +""" +api_system.py : Trovebox System API Classes +""" +from .api_base import ApiBase + +class ApiSystem(ApiBase): + """ Definitions of /system/ API endpoints """ + def version(self, **kwds): + """ + Endpoint: /system/version.json + + Returns a dictionary containing the various server version strings + """ + return self._client.get("/system/version.json", **kwds)["result"] + + def diagnostics(self, **kwds): + """ + Endpoint: /system/diagnostics.json + + Runs a set of diagnostic tests on the server. + Returns a dictionary containing the results. + """ + # Don't process the result automatically, since this raises an exception + # on failure, which doesn't provide the cause of the failure + self._client.get("/system/diagnostics.json", process_response=False, + **kwds) + return self._client.last_response.json()["result"] diff --git a/trovebox/api/api_tag.py b/trovebox/api/api_tag.py new file mode 100644 index 0000000..898cffe --- /dev/null +++ b/trovebox/api/api_tag.py @@ -0,0 +1,60 @@ +""" +api_tag.py : Trovebox Tag API Classes +""" +try: + from urllib.parse import quote # Python3 +except ImportError: + from urllib import quote # Python2 + +from trovebox.objects.tag import Tag +from .api_base import ApiBase + +class ApiTags(ApiBase): + """ Definitions of /tags/ API endpoints """ + def list(self, **kwds): + """ + Endpoint: /tags/list.json + + Returns a list of Tag objects. + """ + tags = self._client.get("/tags/list.json", **kwds)["result"] + tags = self._result_to_list(tags) + return [Tag(self._client, tag) for tag in tags] + +class ApiTag(ApiBase): + """ Definitions of /tag/ API endpoints """ + def create(self, tag, **kwds): + """ + Endpoint: /tag/create.json + + Creates a new tag. + Returns True if successful. + Raises a TroveboxError if not. + """ + return self._client.post("/tag/create.json", tag=tag, **kwds)["result"] + + def delete(self, tag, **kwds): + """ + Endpoint: /tag//delete.json + + Deletes a tag. + Returns True if successful. + Raises a TroveboxError if not. + """ + return self._client.post("/tag/%s/delete.json" % + quote(self._extract_id(tag)), + **kwds)["result"] + + def update(self, tag, **kwds): + """ + Endpoint: /tag//update.json + + Updates a tag with the specified parameters. + Returns the updated tag object. + """ + result = self._client.post("/tag/%s/update.json" % + quote(self._extract_id(tag)), + **kwds)["result"] + return Tag(self._client, result) + + # def view(self, tag, **kwds): diff --git a/trovebox/api_album.py b/trovebox/api_album.py deleted file mode 100644 index 4b69f87..0000000 --- a/trovebox/api_album.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -api_album.py : Trovebox Album API Classes -""" -from .objects import Album - -class ApiAlbums(object): - 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(object): - 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. - Returns True if successful. - Raises an TroveboxError if not. - """ - if not isinstance(album, Album): - album = Album(self._client, {"id": album}) - return album.delete(**kwds) - - def form(self, album, **kwds): - """ Not yet implemented """ - raise NotImplementedError() - - def add_photos(self, album, photos, **kwds): - """ Not yet implemented """ - raise NotImplementedError() - - def remove_photos(self, album, photos, **kwds): - """ Not yet implemented """ - raise NotImplementedError() - - def update(self, album, **kwds): - """ Update an album """ - if not isinstance(album, Album): - album = Album(self._client, {"id": album}) - album.update(**kwds) - 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/trovebox/api_photo.py b/trovebox/api_photo.py deleted file mode 100644 index 702a3de..0000000 --- a/trovebox/api_photo.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -api_photo.py : Trovebox Photo API Classes -""" -import base64 - -from .errors import TroveboxError -from . import http -from .objects import Photo - -def extract_ids(photos): - """ - Given a list of objects, extract the photo id for each Photo - object. - """ - ids = [] - for photo in photos: - if isinstance(photo, Photo): - ids.append(photo.id) - else: - ids.append(photo) - return ids - -class ApiPhotos(object): - 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 = http.result_to_list(photos) - return [Photo(self._client, photo) for photo in photos] - - def update(self, photos, **kwds): - """ - Updates a list of photos. - Returns True if successful. - Raises TroveboxError if not. - """ - ids = extract_ids(photos) - if not self._client.post("/photos/update.json", ids=ids, - **kwds)["result"]: - raise TroveboxError("Update response returned False") - return True - - def delete(self, photos, **kwds): - """ - Deletes a list of photos. - Returns True if successful. - Raises TroveboxError if not. - """ - ids = extract_ids(photos) - if not self._client.post("/photos/delete.json", ids=ids, - **kwds)["result"]: - raise TroveboxError("Delete response returned False") - return True - -class ApiPhoto(object): - def __init__(self, client): - self._client = client - - def delete(self, photo, **kwds): - """ - Delete a photo. - Returns True if successful. - Raises an TroveboxError if not. - """ - if not isinstance(photo, Photo): - photo = Photo(self._client, {"id": photo}) - return 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): - """ Not yet implemented """ - raise NotImplementedError() - - def replace_encoded(self, photo, photo_file, **kwds): - """ Not yet implemented """ - 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): - """ Uploads the specified file to the server """ - with open(photo_file, 'rb') as in_file: - result = self._client.post("/photo/upload.json", - files={'photo': in_file}, - **kwds)["result"] - return Photo(self._client, result) - - def upload_encoded(self, photo_file, **kwds): - """ Base64-encodes and uploads the specified file """ - with open(photo_file, "rb") as in_file: - encoded_photo = base64.b64encode(in_file.read()) - result = self._client.post("/photo/upload.json", photo=encoded_photo, - **kwds)["result"] - return Photo(self._client, result) - - def dynamic_url(self, photo, **kwds): - """ Not yet implemented """ - 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): - """ - 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/trovebox/api_tag.py b/trovebox/api_tag.py deleted file mode 100644 index 0a694a6..0000000 --- a/trovebox/api_tag.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -api_tag.py : Trovebox Tag API Classes -""" -from .objects import Tag - -class ApiTags(object): - 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(object): - def __init__(self, client): - self._client = client - - def create(self, tag, **kwds): - """ - 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. - Returns True if successful. - Raises an TroveboxError if not. - """ - if not isinstance(tag, Tag): - tag = Tag(self._client, {"id": tag}) - return 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/trovebox/auth.py b/trovebox/auth.py index 378ee65..b3e29ed 100644 --- a/trovebox/auth.py +++ b/trovebox/auth.py @@ -9,7 +9,7 @@ from ConfigParser import SafeConfigParser as ConfigParser # Python2 try: import io # Python3 -except ImportError: +except ImportError: # pragma: no cover import StringIO as io # Python2 class Auth(object): diff --git a/trovebox/errors.py b/trovebox/errors.py index 15b13cc..7d225e0 100644 --- a/trovebox/errors.py +++ b/trovebox/errors.py @@ -2,7 +2,7 @@ errors.py : Trovebox Error Classes """ class TroveboxError(Exception): - """ Indicates that an Trovebox operation failed """ + """ Indicates that a Trovebox operation failed """ pass class TroveboxDuplicateError(TroveboxError): diff --git a/trovebox/http.py b/trovebox/http.py index 4cc0faf..0a99fab 100644 --- a/trovebox/http.py +++ b/trovebox/http.py @@ -11,13 +11,13 @@ except ImportError: from urlparse import urlparse, urlunparse # Python2 -from .objects import TroveboxObject -from .errors import * +from trovebox.objects.trovebox_object import TroveboxObject +from .errors import TroveboxError, Trovebox404Error, TroveboxDuplicateError from .auth import Auth if sys.version < '3': TEXT_TYPE = unicode -else: +else: # pragma: no cover TEXT_TYPE = str DUPLICATE_RESPONSE = {"code": 409, @@ -25,7 +25,7 @@ class Http(object): """ - Base class to handle HTTP requests to an Trovebox server. + Base class to handle HTTP requests to a Trovebox server. If no parameters are specified, auth config is loaded from the default location (~/.config/trovebox/default). The config_file parameter is used to specify an alternate config file. @@ -43,7 +43,7 @@ def __init__(self, config_file=None, host=None, self.config = dict(self._CONFIG_DEFAULTS) - if api_version is not None: + if api_version is not None: # pragma: no cover print("Deprecation Warning: api_version should be set by " "calling the configure function") self.config["api_version"] = api_version @@ -104,7 +104,9 @@ def get(self, endpoint, process_response=True, **params): self._logger.info("============================") self._logger.info("GET %s" % url) self._logger.info("---") - self._logger.info(response.text) + self._logger.info(response.text[:1000]) + if len(response.text) > 1000: # pragma: no cover + self._logger.info("[Response truncated to 1000 characters]") self.last_url = url self.last_params = params @@ -113,7 +115,11 @@ def get(self, endpoint, process_response=True, **params): if process_response: return self._process_response(response) else: - return response.text + if 200 <= response.status_code < 300: + return response.text + else: + raise TroveboxError("HTTP Error %d: %s" % + (response.status_code, response.reason)) def post(self, endpoint, process_response=True, files=None, **params): """ @@ -154,7 +160,9 @@ def post(self, endpoint, process_response=True, files=None, **params): if files: self._logger.info("files: %s" % repr(files)) self._logger.info("---") - self._logger.info(response.text) + self._logger.info(response.text[:1000]) + if len(response.text) > 1000: # pragma: no cover + self._logger.info("[Response truncated to 1000 characters]") self.last_url = url self.last_params = params @@ -163,7 +171,11 @@ def post(self, endpoint, process_response=True, files=None, **params): if process_response: return self._process_response(response) else: - return response.text + if 200 <= response.status_code < 300: + return response.text + else: + raise TroveboxError("HTTP Error %d: %s" % + (response.status_code, response.reason)) def _construct_url(self, endpoint): """Return the full URL to the specified endpoint""" @@ -241,12 +253,3 @@ def _process_response(response): raise TroveboxDuplicateError("Code %d: %s" % (code, message)) else: raise TroveboxError("Code %d: %s" % (code, message)) - -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/trovebox/main.py b/trovebox/main.py index b7b84e4..081597c 100644 --- a/trovebox/main.py +++ b/trovebox/main.py @@ -27,6 +27,7 @@ ################################################################# def main(args=sys.argv[1:]): + """Run the commandline script""" usage = "%prog --help" parser = OptionParser(usage, add_help_option=False) parser.add_option('-c', '--config', help="Configuration file to use", @@ -90,8 +91,8 @@ def main(args=sys.argv[1:]): params, files = extract_files(params) result = client.post(options.endpoint, process_response=False, files=files, **params) - for f in files: - files[f].close() + for file_ in files: + files[file_].close() if options.verbose: print("==========\nMethod: %s\nHost: %s\nEndpoint: %s" % @@ -129,5 +130,5 @@ def extract_files(params): return updated_params, files -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover main() diff --git a/trovebox/objects.py b/trovebox/objects.py deleted file mode 100644 index 54eaed2..0000000 --- a/trovebox/objects.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -objects.py : Basic Trovebox API Objects -""" -try: - from urllib.parse import quote # Python3 -except ImportError: - from urllib import quote # Python2 - -from .errors import TroveboxError - -class TroveboxObject(object): - """ Base object supporting the storage of custom fields as attributes """ - def __init__(self, trovebox, json_dict): - self.id = None - self.name = None - self._trovebox = trovebox - 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 not key.startswith("_"): - 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(): - if not key.startswith("_"): - delattr(self, key) - self._json_dict = json_dict - self._set_fields(json_dict) - - def _delete_fields(self): - """ - Delete this object's attributes, including name and id - """ - for key in self._json_dict.keys(): - if not key.startswith("_"): - delattr(self, key) - self._json_dict = {} - self.id = None - self.name = None - - def __repr__(self): - if self.name is not None: - return "<%s name='%s'>" % (self.__class__, self.name) - elif self.id is not None: - 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(TroveboxObject): - def delete(self, **kwds): - """ - Delete this photo. - Returns True if successful. - Raises an TroveboxError if not. - """ - result = self._trovebox.post("/photo/%s/delete.json" % - self.id, **kwds)["result"] - if not result: - raise TroveboxError("Delete response returned False") - self._delete_fields() - return result - - def edit(self, **kwds): - """ Returns an HTML form to edit the photo """ - result = self._trovebox.get("/photo/%s/edit.json" % - self.id, **kwds)["result"] - return result["markup"] - - def replace(self, photo_file, **kwds): - """ Not implemented yet """ - raise NotImplementedError() - - def replace_encoded(self, photo_file, **kwds): - """ Not implemented yet """ - raise NotImplementedError() - - def update(self, **kwds): - """ Update this photo with the specified parameters """ - new_dict = self._trovebox.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._trovebox.get("/photo/%s/view.json" % - self.id, **kwds)["result"] - self._replace_fields(new_dict) - - def dynamic_url(self, **kwds): - """ Not implemented yet """ - 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._trovebox.get("/photo/%s/nextprevious.json" % - self.id, **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._trovebox, 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._trovebox, photo)) - - return value - - def transform(self, **kwds): - """ - Performs transformation specified in **kwds - Example: transform(rotate=90) - """ - new_dict = self._trovebox.post("/photo/%s/transform.json" % - self.id, **kwds)["result"] - - # APIv1 doesn't return the transformed photo (frontend issue #955) - if isinstance(new_dict, bool): - new_dict = self._trovebox.get("/photo/%s/view.json" % - self.id)["result"] - - self._replace_fields(new_dict) - -class Tag(TroveboxObject): - def delete(self, **kwds): - """ - Delete this tag. - Returns True if successful. - Raises an TroveboxError if not. - """ - result = self._trovebox.post("/tag/%s/delete.json" % - quote(self.id), **kwds)["result"] - if not result: - raise TroveboxError("Delete response returned False") - self._delete_fields() - return result - - def update(self, **kwds): - """ Update this tag with the specified parameters """ - new_dict = self._trovebox.post("/tag/%s/update.json" % quote(self.id), - **kwds)["result"] - self._replace_fields(new_dict) - - -class Album(TroveboxObject): - def __init__(self, trovebox, json_dict): - self.photos = None - self.cover = None - TroveboxObject.__init__(self, trovebox, 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 isinstance(self.cover, dict): - self.cover = Photo(self._trovebox, self.cover) - # Update the photo list with photo objects - if isinstance(self.photos, list): - for i, photo in enumerate(self.photos): - if isinstance(photo, dict): - self.photos[i] = Photo(self._trovebox, photo) - - def delete(self, **kwds): - """ - Delete this album. - Returns True if successful. - Raises an TroveboxError if not. - """ - result = self._trovebox.post("/album/%s/delete.json" % - self.id, **kwds)["result"] - if not result: - raise TroveboxError("Delete response returned False") - self._delete_fields() - return result - - def form(self, **kwds): - """ Not implemented yet """ - raise NotImplementedError() - - def add_photos(self, photos, **kwds): - """ Not implemented yet """ - raise NotImplementedError() - - def remove_photos(self, photos, **kwds): - """ Not implemented yet """ - raise NotImplementedError() - - def update(self, **kwds): - """ Update this album with the specified parameters """ - new_dict = self._trovebox.post("/album/%s/update.json" % - self.id, **kwds)["result"] - - # APIv1 doesn't return the updated album (frontend issue #937) - if isinstance(new_dict, bool): - new_dict = self._trovebox.get("/album/%s/view.json" % - self.id)["result"] - - self._replace_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._trovebox.get("/album/%s/view.json" % - self.id, **kwds)["result"] - self._replace_fields(result) - self._update_fields_with_objects() diff --git a/trovebox/objects/__init__.py b/trovebox/objects/__init__.py new file mode 100644 index 0000000..a64f448 --- /dev/null +++ b/trovebox/objects/__init__.py @@ -0,0 +1,4 @@ +""" +trovebox.objects Package + Object classes returned by the API. +""" diff --git a/trovebox/objects/action.py b/trovebox/objects/action.py new file mode 100644 index 0000000..955c238 --- /dev/null +++ b/trovebox/objects/action.py @@ -0,0 +1,48 @@ +""" +Representation of an Action object +""" +from .trovebox_object import TroveboxObject +from .photo import Photo + +class Action(TroveboxObject): + """ Representation of an Action object """ + _type = "action" + + def __init__(self, client, json_dict): + self.target = None + self.target_type = None + TroveboxObject.__init__(self, client, json_dict) + self._update_fields_with_objects() + + def _update_fields_with_objects(self): + """ Convert dict fields into objects, where appropriate """ + # Update the photo target with photo objects + if self.target is not None: + if self.target_type == "photo": + self.target = Photo(self._client, self.target) + else: + raise NotImplementedError("Actions can only be assigned to " + "Photos") + + def delete(self, **kwds): + """ + Endpoint: /action//delete.json + + Deletes this action. + Returns True if successful. + Raises a TroveboxError if not. + """ + result = self._client.action.delete(self, **kwds) + self._delete_fields() + return result + + def view(self, **kwds): + """ + Endpoint: /action//view.json + + Requests the full contents of the action. + Updates the action object's fields with the response. + """ + result = self._client.action.view(self, **kwds) + self._replace_fields(result.get_fields()) + self._update_fields_with_objects() diff --git a/trovebox/objects/activity.py b/trovebox/objects/activity.py new file mode 100644 index 0000000..e95c2da --- /dev/null +++ b/trovebox/objects/activity.py @@ -0,0 +1,37 @@ +""" +Representation of an Activity object +""" +from .trovebox_object import TroveboxObject +from .photo import Photo + +class Activity(TroveboxObject): + """ Representation of an Activity object """ + _type = "activity" + + def __init__(self, client, json_dict): + self.data = None + self.type = None + TroveboxObject.__init__(self, client, json_dict) + self._update_fields_with_objects() + + def _update_fields_with_objects(self): + """ Convert dict fields into objects, where appropriate """ + # Update the data with photo objects + if self.type is not None: + if self.type.startswith("photo"): + self.data = Photo(self._client, self.data) + else: + raise NotImplementedError("Unrecognised activity type: %s" + % self.type) + + def view(self, **kwds): + """ + Endpoint: /activity//view.json + + Requests the full contents of the activity. + Updates the activity's fields with the response. + """ + result = self._client.activity.view(self, **kwds) + self._replace_fields(result.get_fields()) + self._update_fields_with_objects() + diff --git a/trovebox/objects/album.py b/trovebox/objects/album.py new file mode 100644 index 0000000..7a335cd --- /dev/null +++ b/trovebox/objects/album.py @@ -0,0 +1,101 @@ +""" +Representation of an Album object +""" +from .trovebox_object import TroveboxObject +from .photo import Photo + +class Album(TroveboxObject): + """ Representation of an Album object """ + _type = "album" + + def __init__(self, client, json_dict): + self.photos = None + self.cover = None + TroveboxObject.__init__(self, client, 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 isinstance(self.cover, dict): + self.cover = Photo(self._client, self.cover) + + # Update the photo list with photo objects + try: + for i, photo in enumerate(self.photos): + if isinstance(photo, dict): + self.photos[i] = Photo(self._client, photo) + except (AttributeError, TypeError): + pass # No photos, or not a list + + def cover_update(self, photo, **kwds): + """ + Endpoint: /album//cover//update.json + + Update the cover photo of this album. + """ + result = self._client.album.cover_update(self, photo, **kwds) + self._replace_fields(result.get_fields()) + self._update_fields_with_objects() + + def delete(self, **kwds): + """ + Endpoint: /album//delete.json + + Deletes this album. + Returns True if successful. + Raises a TroveboxError if not. + """ + result = self._client.album.delete(self, **kwds) + self._delete_fields() + return result + + def add(self, objects, object_type=None, **kwds): + """ + Endpoint: /album///add.json + + Add objects (eg. Photos) to this album. + The objects are a list of either IDs or Trovebox objects. + If Trovebox objects are used, the object type is inferred + automatically. + Updates the album's fields with the response. + """ + result = self._client.album.add(self, objects, object_type, **kwds) + self._replace_fields(result.get_fields()) + self._update_fields_with_objects() + + def remove(self, objects, object_type=None, **kwds): + """ + Endpoint: /album///remove.json + + Remove objects (eg. Photos) from this album. + The objects are a list of either IDs or Trovebox objects. + If Trovebox objects are used, the object type is inferred + automatically. + Updates the album's fields with the response. + """ + result = self._client.album.remove(self, objects, object_type, + **kwds) + self._replace_fields(result.get_fields()) + self._update_fields_with_objects() + + def update(self, **kwds): + """ + Endpoint: /album//update.json + + Updates this album with the specified parameters. + """ + result = self._client.album.update(self, **kwds) + self._replace_fields(result.get_fields()) + self._update_fields_with_objects() + + def view(self, **kwds): + """ + Endpoint: /album//view.json + + Requests all properties of an album. + Updates the album's fields with the response. + """ + result = self._client.album.view(self, **kwds) + self._replace_fields(result.get_fields()) + self._update_fields_with_objects() diff --git a/trovebox/objects/photo.py b/trovebox/objects/photo.py new file mode 100644 index 0000000..d2aaf19 --- /dev/null +++ b/trovebox/objects/photo.py @@ -0,0 +1,102 @@ +""" +Representation of a Photo object +""" +from .trovebox_object import TroveboxObject + +class Photo(TroveboxObject): + """ Representation of a Photo object """ + _type = "photo" + + def delete(self, **kwds): + """ + Endpoint: /photo//delete.json + + Deletes this photo. + Returns True if successful. + Raises a TroveboxError if not. + """ + result = self._client.photo.delete(self, **kwds) + self._delete_fields() + return result + + def delete_source(self, **kwds): + """ + Endpoint: /photo//source/delete.json + + Deletes the source files of this photo. + Returns True if successful. + Raises a TroveboxError if not. + """ + return self._client.photo.delete_source(self, **kwds) + + def replace(self, photo_file, **kwds): + """ + Endpoint: /photo//replace.json + + Uploads the specified photo file to replace this photo. + """ + result = self._client.photo.replace(self, photo_file, **kwds) + self._replace_fields(result.get_fields()) + + def replace_encoded(self, photo_file, **kwds): + """ + Endpoint: /photo//replace.json + + Base64-encodes and uploads the specified photo file to + replace this photo. + """ + result = self._client.photo.replace_encoded(self, photo_file, + **kwds) + self._replace_fields(result.get_fields()) + + def replace_from_url(self, url, **kwds): + """ + Endpoint: /photo/replace.json + + Import a photo from the specified URL to replace this photo. + """ + result = self._client.photo.replace_from_url(self, url, **kwds) + self._replace_fields(result.get_fields()) + + def update(self, **kwds): + """ + Endpoint: /photo//update.json + + Updates this photo with the specified parameters. + """ + result = self._client.photo.update(self, **kwds) + self._replace_fields(result.get_fields()) + + def view(self, options=None, **kwds): + """ + Endpoint: /photo/[/]/view.json + + Requests all properties of this photo. + Can be used to obtain URLs for the photo at a particular size, + by using the "returnSizes" parameter. + Updates the photo's fields with the response. + The options parameter can be used to pass in additional options. + Eg: options={"token": } + """ + result = self._client.photo.view(self, options, **kwds) + self._replace_fields(result.get_fields()) + + def next_previous(self, options=None, **kwds): + """ + Endpoint: /photo//nextprevious[/].json + + Returns a dict containing the next and previous photo lists + (there may be more than one next/previous photo returned). + """ + return self._client.photo.next_previous(self, options, **kwds) + + def transform(self, **kwds): + """ + Endpoint: /photo//transform.json + + Performs the specified transformations. + eg. transform(photo, rotate=90) + Updates the photo's fields with the response. + """ + result = self._client.photo.transform(self, **kwds) + self._replace_fields(result.get_fields()) diff --git a/trovebox/objects/tag.py b/trovebox/objects/tag.py new file mode 100644 index 0000000..19326d4 --- /dev/null +++ b/trovebox/objects/tag.py @@ -0,0 +1,32 @@ +""" +Representation of a Tag object +""" +from .trovebox_object import TroveboxObject + +class Tag(TroveboxObject): + """ Representation of a Tag object """ + _type = "tag" + + def delete(self, **kwds): + """ + Endpoint: /tag//delete.json + + Deletes this tag. + Returns True if successful. + Raises a TroveboxError if not. + """ + result = self._client.tag.delete(self, **kwds) + self._delete_fields() + return result + + def update(self, **kwds): + """ + Endpoint: /tag//update.json + + Updates this tag with the specified parameters. + Returns the updated tag object. + """ + result = self._client.tag.update(self, **kwds) + self._replace_fields(result.get_fields()) + + # def view(self, **kwds): diff --git a/trovebox/objects/trovebox_object.py b/trovebox/objects/trovebox_object.py new file mode 100644 index 0000000..2e02eca --- /dev/null +++ b/trovebox/objects/trovebox_object.py @@ -0,0 +1,56 @@ +""" +Base object supporting the storage of custom fields as attributes +""" +class TroveboxObject(object): + """ Base object supporting the storage of custom fields as attributes """ + _type = "None" + def __init__(self, client, json_dict): + self.id = None + self.name = None + self._client = client + 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 not key.startswith("_"): + 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(): + if not key.startswith("_"): + delattr(self, key) + self._json_dict = json_dict + self._set_fields(json_dict) + + def _delete_fields(self): + """ + Delete this object's attributes, including name and id + """ + for key in self._json_dict.keys(): + if not key.startswith("_"): + delattr(self, key) + self._json_dict = {} + self.id = None + self.name = None + + def __repr__(self): + if self.name is not None: + return "<%s name='%s'>" % (self.__class__.__name__, self.name) + elif self.id is not None: + return "<%s id='%s'>" % (self.__class__.__name__, self.id) + else: + return "<%s>" % (self.__class__.__name__) + + def get_fields(self): + """ Returns this object's attributes """ + return self._json_dict + + def get_type(self): + """ Return this object's type (eg. "photo") """ + return self._type