diff --git a/README.markdown b/README.markdown index c0e1c8d..619b7fa 100644 --- a/README.markdown +++ b/README.markdown @@ -8,38 +8,56 @@ Open Photo API / Python Library python setup.py install ---------------------------------------- + +### Credentials +For full access to your photos, you need to create the following config file in ``~/.config/openphoto/default`` + + # ~/.config/openphoto/default + host = your.host.com + consumerKey = your_consumer_key + consumerSecret = your_consumer_secret + token = your_access_token + tokenSecret = your_access_token_secret + +The ``config_file`` switch lets you specify a different config file. + +To get your credentials: + * Log into your Trovebox site + * Click the arrow on the top-right and select 'Settings' + * Click the 'Create a new app' button + * Click the 'View' link beside the newly created app + +---------------------------------------- ### How to use the library -To use the library you need to first ``import openphoto`` then instantiate an instance of the class and start making calls. - You can use the library in one of two ways: * Direct GET/POST calls to the server * Access via Python classes/methods -### Direct GET/POST: +#### Direct GET/POST: from openphoto import OpenPhoto - client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret) + client = OpenPhoto() resp = client.get("/photos/list.json") resp = client.post("/photo/62/update.json", tags=["tag1", "tag2"]) -### Python classes/methods +#### Python classes/methods from openphoto import OpenPhoto - client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret) + client = OpenPhoto() photos = client.photos.list() photos[0].update(tags=["tag1", "tag2"]) print photos[0].tags The OpenPhoto Python class hierarchy mirrors the [OpenPhoto API](http://theopenphotoproject.org/documentation) endpoint layout. For example, the calls in the example above use the following API endpoints: -* client.photos.list() -> /photos/list.json -* photos[0].update() -> /photo/<id>/update.json +* ``client.photos.list() -> /photos/list.json`` +* ``photos[0].update() -> /photo//update.json`` ### API Versioning @@ -50,38 +68,28 @@ This ensures that future API updates won't cause unexpected breakages. To do this, add the optional ```api_version``` parameter when creating the client object: from openphoto import OpenPhoto - client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret, api_version=2) + client = OpenPhoto(api_version=2) ---------------------------------------- ### Using from the command line -When using the command line tool, you'll want to export your authentication credentials to the environment. -The command line tool will look for the following config file in ~/.config/openphoto/default -(the -c switch lets you specify a different config file): - - # ~/.config/openphoto/default - host = your.host.com - consumerKey = your_consumer_key - consumerSecret = your_consumer_secret - token = your_access_token - tokenSecret = your_access_token_secret - -Click here for instructions on getting credentials. +You can run commands to the OpenPhoto API from your shell! These are the options you can pass to the shell program: -h # Display help text -c config_file # Either the name of a config file in ~/.config/openphoto/ or a full path to a config file - -H hostname # Overrides config_file for unauthenticated API calls [default=localhost] + -H hostname # Overrides config_file for unauthenticated API calls -e endpoint # [default=/photos/list.json] -X method # [default=GET] -F params # e.g. -F 'title=my title' -F 'tags=mytag1,mytag2' -p # Pretty print the json -v # Verbose output -You can run commands to the OpenPhoto API from your shell! + +#### Command line examples # Upload a public photo to the host specified in ~/.config/openphoto/default openphoto -p -X POST -e /photo/upload.json -F 'photo=@/path/to/photo/jpg' -F 'permission=1' @@ -120,9 +128,3 @@ You can run commands to the OpenPhoto API from your shell! ... } } - - -#### Getting your credentials - -You can get your credentals by clicking on the arrow next to your email address once you're logged into your site and then clicking on settings. -If you don't have any credentials then you can create one for yourself using the "Create a new app" button. diff --git a/openphoto/__init__.py b/openphoto/__init__.py index 8132570..ce569c5 100644 --- a/openphoto/__init__.py +++ b/openphoto/__init__.py @@ -8,18 +8,21 @@ LATEST_API_VERSION = 2 class OpenPhoto(OpenPhotoHttp): """ - Python client library for the specified OpenPhoto host. - OAuth tokens (consumer*, token*) can optionally be specified. - + Client library for OpenPhoto + If no parameters are specified, config is loaded from the default + location (~/.config/openphoto/default). + The config_file parameter is used to specify an alternate config file. + If the host parameter is specified, no config file is loaded and + OAuth tokens (consumer*, token*) can optionally be specified. All requests will include the api_version path, if specified. This should be used to ensure that your application will continue to work - even if the OpenPhoto API is updated to a new revision. + even if the OpenPhoto API is updated to a new revision. """ - def __init__(self, host, + def __init__(self, config_file=None, host=None, consumer_key='', consumer_secret='', token='', token_secret='', api_version=None): - OpenPhotoHttp.__init__(self, host, + OpenPhotoHttp.__init__(self, config_file, host, consumer_key, consumer_secret, token, token_secret, api_version) diff --git a/openphoto/main.py b/openphoto/main.py index 0a06f53..30d3eac 100644 --- a/openphoto/main.py +++ b/openphoto/main.py @@ -3,8 +3,6 @@ import os import sys import string import urllib -import StringIO -import ConfigParser from optparse import OptionParser try: @@ -14,47 +12,6 @@ except ImportError: from openphoto import OpenPhoto -def get_config_path(config_file): - config_path = os.getenv('XDG_CONFIG_HOME') - if not config_path: - config_path = os.path.join(os.getenv('HOME'), ".config") - if not config_file: - config_file = "default" - return os.path.join(config_path, "openphoto", config_file) - -def read_config(config_file): - """ - Loads config data from the specified file. - If config_file doesn't exist, returns an empty authentication config for localhost. - """ - section = "DUMMY" - defaults = {'host': 'localhost', - 'consumerKey': '', 'consumerSecret': '', - 'token': '', 'tokenSecret':'', - } - # Insert an section header at the start of the config file, so ConfigParser can understand it - # Also prepend a [DEFAULT] section, since it's the only way to specify case-sensitive defaults - buf = StringIO.StringIO() - buf.write("[DEFAULT]\n") - for key in defaults: - buf.write("%s=%s\n" % (key, defaults[key])) - buf.write('[%s]\n' % section) - if os.path.isfile(config_file): - buf.write(open(config_file).read()) - else: - print "Config file '%s' doesn't exist - authentication won't be used" % config_file - - buf.seek(0, os.SEEK_SET) - parser = ConfigParser.SafeConfigParser() - parser.optionxform = str # Case-sensitive options - parser.readfp(buf) - - # Trim quotes - config = parser.items(section) - config = [(item[0], item[1].replace('"', '')) for item in config] - config = [(item[0], item[1].replace("'", "")) for item in config] - return dict(config) - ################################################################# def main(args=sys.argv[1:]): @@ -85,16 +42,28 @@ def main(args=sys.argv[1:]): # Host option overrides config file settings if options.host: - config = {'host': options.host, 'consumerKey': '', 'consumerSecret': '', - 'token': '', 'tokenSecret': ''} + client = OpenPhoto(host=options.host) else: - config_path = get_config_path(options.config_file) - config = read_config(config_path) - if options.verbose: - print "Using config from '%s'" % config_path - - client = OpenPhoto(config['host'], config['consumerKey'], config['consumerSecret'], - config['token'], config['tokenSecret']) + try: + client = OpenPhoto(config_file=options.config_file) + except IOError as error: + print error + print + print "You must create a configuration file with the following contents:" + print " host = your.host.com" + print " consumerKey = your_consumer_key" + print " consumerSecret = your_consumer_secret" + print " token = your_access_token" + print " tokenSecret = your_access_token_secret" + print + print "To get your credentials:" + print " * Log into your Trovebox site" + print " * Click the arrow on the top-right and select 'Settings'." + print " * Click the 'Create a new app' button." + print " * Click the 'View' link beside the newly created app." + print + print error + sys.exit(1) if options.method == "GET": result = client.get(options.endpoint, process_response=False, **params) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 2e8bfb3..0657161 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -1,8 +1,11 @@ +import os import oauth2 as oauth import urlparse import urllib import httplib2 import logging +import StringIO +import ConfigParser try: import json except ImportError: @@ -16,18 +19,42 @@ DUPLICATE_RESPONSE = {"code": 409, "message": "This photo already exists"} class OpenPhotoHttp: - """ Base class to handle HTTP requests to an OpenPhoto server """ - def __init__(self, host, consumer_key='', consumer_secret='', + """ + Base class to handle HTTP requests to an OpenPhoto server. + If no parameters are specified, config is loaded from the default + location (~/.config/openphoto/default). + The config_file parameter is used to specify an alternate config file. + If the host parameter is specified, no config file is loaded and + OAuth tokens (consumer*, token*) can optionally be specified. + All requests will include the api_version path, if specified. + This should be used to ensure that your application will continue to work + even if the OpenPhoto API is updated to a new revision. + """ + def __init__(self, config_file=None, host=None, + consumer_key='', consumer_secret='', token='', token_secret='', api_version=None): - self._host = host - self._consumer_key = consumer_key - self._consumer_secret = consumer_secret - self._token = token - self._token_secret = token_secret self._api_version = api_version self._logger = logging.getLogger("openphoto") + if host is None: + self.config_path = self._get_config_path(config_file) + config = self._read_config(self.config_path) + self._host = config['host'] + self._consumer_key = config['consumerKey'] + self._consumer_secret = config['consumerSecret'] + self._token = config['token'] + self._token_secret = config['tokenSecret'] + else: + self._host = host + self._consumer_key = consumer_key + self._consumer_secret = consumer_secret + self._token = token + self._token_secret = token_secret + + if host is not None and config_file is not None: + raise ValueError("Cannot specify both host and config_file") + # Remember the most recent HTTP request and response self.last_url = None self.last_params = None @@ -36,12 +63,12 @@ class OpenPhotoHttp: def get(self, endpoint, process_response=True, **params): """ Performs an HTTP GET from the specified endpoint (API path), - passing parameters if given. + passing parameters if given. The api_version is prepended to the endpoint, - if it was specified when the OpenPhoto object was created. + if it was specified when the OpenPhoto object was created. Returns the decoded JSON dictionary, and raises exceptions if an - error code is received. + error code is received. Returns the raw response if process_response=False """ params = self._process_params(params) @@ -77,12 +104,12 @@ class OpenPhotoHttp: def post(self, endpoint, process_response=True, files = {}, **params): """ Performs an HTTP POST to the specified endpoint (API path), - passing parameters if given. + passing parameters if given. The api_version is prepended to the endpoint, - if it was specified when the OpenPhoto object was created. + if it was specified when the OpenPhoto object was created. Returns the decoded JSON dictionary, and raises exceptions if an - error code is received. + error code is received. Returns the raw response if process_response=False """ params = self._process_params(params) @@ -205,3 +232,42 @@ class OpenPhotoHttp: return [] else: return result + + @staticmethod + def _get_config_path(config_file): + config_path = os.getenv('XDG_CONFIG_HOME') + if not config_path: + config_path = os.path.join(os.getenv('HOME'), ".config") + if not config_file: + config_file = "default" + return os.path.join(config_path, "openphoto", config_file) + + def _read_config(self, config_file): + """ + Loads config data from the specified file. + If config_file doesn't exist, returns an empty authentication config for localhost. + """ + section = "DUMMY" + defaults = {'host': 'localhost', + 'consumerKey': '', 'consumerSecret': '', + 'token': '', 'tokenSecret':'', + } + # Insert an section header at the start of the config file, so ConfigParser can understand it + # Also prepend a [DEFAULT] section, since it's the only way to specify case-sensitive defaults + buf = StringIO.StringIO() + buf.write("[DEFAULT]\n") + for key in defaults: + buf.write("%s=%s\n" % (key, defaults[key])) + buf.write('[%s]\n' % section) + buf.write(open(config_file).read()) + + buf.seek(0, os.SEEK_SET) + parser = ConfigParser.SafeConfigParser() + parser.optionxform = str # Case-sensitive options + parser.readfp(buf) + + # Trim quotes + config = parser.items(section) + config = [(item[0].replace('"', ''), item[1].replace('"', '')) for item in config] + config = [(item[0].replace("'", ""), item[1].replace("'", "")) for item in config] + return dict(config) diff --git a/tests/README.markdown b/tests/README.markdown index 3f3e2b8..b8f8cd7 100644 --- a/tests/README.markdown +++ b/tests/README.markdown @@ -5,23 +5,27 @@ Tests for the Open Photo API / Python Library ---------------------------------------- ### Requirements -A computer, Python 2.7 and an empty OpenPhoto instance. +A computer, Python 2.7 and an empty OpenPhoto test host. --------------------------------------- ### Setting up -Create a tests/tokens.py file containing the following: +Create a ``~/.config/openphoto/test`` config file containing the following: - # tests/tokens.py - consumer_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - consumer_secret = "xxxxxxxxxx" - token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - token_secret = "xxxxxxxxxx" - host = "your_hostname" + # ~/.config/openphoto/test + host = your.host.com + consumerKey = your_consumer_key + consumerSecret = your_consumer_secret + token = your_access_token + tokenSecret = your_access_token_secret Make sure this is an empty test server, **not a production OpenPhoto server!!!** +You can specify an alternate test config file with the following environment variable: + + export OPENPHOTO_TEST_CONFIG=test2 + --------------------------------------- ### Running the tests diff --git a/tests/test_base.py b/tests/test_base.py index 13ae63a..8d793b9 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,22 +1,8 @@ -import unittest import os +import unittest import logging import openphoto -try: - import tokens -except ImportError: - print ("********************************************************************\n" - "You need to create a 'tokens.py' file containing the following:\n\n" - " host = \"\"\n" - " consumer_key = \"\"\n" - " consumer_secret = \"\"\n" - " token = \"\"\n" - " token_secret = \"\"\n" - "WARNING: Don't use a production OpenPhoto instance for this!\n" - "********************************************************************\n") - raise - def get_test_server_api(): return int(os.getenv("OPENPHOTO_TEST_SERVER_API", openphoto.LATEST_API_VERSION)) @@ -27,6 +13,7 @@ class TestBase(unittest.TestCase): MAXIMUM_TEST_PHOTOS = 4 # Never have more the 4 photos on the test server testcase_name = "(unknown testcase)" api_version = None + config_file = os.getenv("OPENPHOTO_TEST_CONFIG", "test") def __init__(self, *args, **kwds): unittest.TestCase.__init__(self, *args, **kwds) @@ -45,25 +32,23 @@ class TestBase(unittest.TestCase): else: print "\nTesting %s v%d" % (cls.testcase_name, cls.api_version) - cls.client = openphoto.OpenPhoto(tokens.host, - tokens.consumer_key, tokens.consumer_secret, - tokens.token, tokens.token_secret, - cls.api_version) + cls.client = openphoto.OpenPhoto(config_file=cls.config_file, + api_version=cls.api_version) if cls.client.photos.list() != []: raise ValueError("The test server (%s) contains photos. " "Please delete them before running the tests" - % tokens.host) + % cls.client._host) if cls.client.tags.list() != []: raise ValueError("The test server (%s) contains tags. " "Please delete them before running the tests" - % tokens.host) + % cls.client._host) if cls.client.albums.list() != []: raise ValueError("The test server (%s) contains albums. " "Please delete them before running the tests" - % tokens.host) + % cls.client._host) @classmethod def tearDownClass(cls): diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3ce3c03 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,90 @@ +import unittest +import os +import shutil +import openphoto + +CONFIG_HOME_PATH = os.path.join("test", "config") +CONFIG_PATH = os.path.join(CONFIG_HOME_PATH, "openphoto") + +class TestConfig(unittest.TestCase): + def setUp(self): + """ Override XDG_CONFIG_HOME env var, to use test configs """ + try: + self.original_xdg_config_home = os.environ["XDG_CONFIG_HOME"] + except KeyError: + self.original_xdg_config_home = None + os.environ["XDG_CONFIG_HOME"] = CONFIG_HOME_PATH + os.makedirs(CONFIG_PATH) + + def tearDown(self): + if self.original_xdg_config_home is None: + del os.environ["XDG_CONFIG_HOME"] + else: + os.environ["XDG_CONFIG_HOME"] = self.original_xdg_config_home + shutil.rmtree(CONFIG_HOME_PATH, ignore_errors=True) + + def create_config(self, config_file, host): + f = open(os.path.join(CONFIG_PATH, config_file), "w") + f.write("host = %s\n" % host) + f.write("# Comment\n\n") + f.write("consumerKey = \"%s_consumer_key\"\n" % config_file) + f.write("\"consumerSecret\" = %s_consumer_secret\n" % config_file) + f.write("'token'=%s_token\n" % config_file) + f.write("tokenSecret = '%s_token_secret'\n" % config_file) + + def test_default_config(self): + """ Ensure the default config is loaded """ + self.create_config("default", "Test Default Host") + client = openphoto.OpenPhoto() + self.assertEqual(client._host, "Test Default Host") + self.assertEqual(client._consumer_key, "default_consumer_key") + self.assertEqual(client._consumer_secret, "default_consumer_secret") + self.assertEqual(client._token, "default_token") + self.assertEqual(client._token_secret, "default_token_secret") + + def test_custom_config(self): + """ Ensure a custom config can be loaded """ + self.create_config("default", "Test Default Host") + self.create_config("custom", "Test Custom Host") + client = openphoto.OpenPhoto(config_file="custom") + self.assertEqual(client._host, "Test Custom Host") + self.assertEqual(client._consumer_key, "custom_consumer_key") + self.assertEqual(client._consumer_secret, "custom_consumer_secret") + self.assertEqual(client._token, "custom_token") + self.assertEqual(client._token_secret, "custom_token_secret") + + def test_full_config_path(self): + """ Ensure a full custom config path can be loaded """ + self.create_config("path", "Test Path Host") + full_path = os.path.abspath(CONFIG_PATH) + client = openphoto.OpenPhoto(config_file=os.path.join(full_path, "path")) + self.assertEqual(client._host, "Test Path Host") + self.assertEqual(client._consumer_key, "path_consumer_key") + self.assertEqual(client._consumer_secret, "path_consumer_secret") + self.assertEqual(client._token, "path_token") + self.assertEqual(client._token_secret, "path_token_secret") + + def test_host_override(self): + """ Ensure that specifying a host overrides the default config """ + self.create_config("default", "Test Default Host") + client = openphoto.OpenPhoto(host="host_override") + self.assertEqual(client._host, "host_override") + self.assertEqual(client._consumer_key, "") + self.assertEqual(client._consumer_secret, "") + self.assertEqual(client._token, "") + self.assertEqual(client._token_secret, "") + + def test_missing_config_files_raise_exceptions(self): + """ Ensure that missing config files raise exceptions """ + with self.assertRaises(IOError): + openphoto.OpenPhoto() + with self.assertRaises(IOError): + openphoto.OpenPhoto(config_file="custom") + + def test_host_and_config_file_raises_exception(self): + """ It's not valid to specify both a host and a config_file """ + self.create_config("custom", "Test Custom Host") + with self.assertRaises(ValueError): + openphoto.OpenPhoto(config_file="custom", host="host_override") + + diff --git a/tests/test_framework.py b/tests/test_framework.py index be91454..5eb9e8b 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -10,17 +10,10 @@ class TestFramework(test_base.TestBase): """Override the default setUp, since we don't need a populated database""" logging.info("\nRunning %s..." % self.id()) - def create_client_from_base(self, api_version): - return openphoto.OpenPhoto(self.client._host, - self.client._consumer_key, - self.client._consumer_secret, - self.client._token, - self.client._token_secret, - api_version=api_version) - def test_api_version_zero(self): # API v0 has a special hello world message - client = self.create_client_from_base(api_version=0) + client = openphoto.OpenPhoto(config_file=self.config_file, + api_version=0) result = client.get("hello.json") self.assertEqual(result['message'], "Hello, world! This is version zero of the API!") self.assertEqual(result['result']['__route__'], "/v0/hello.json") @@ -28,14 +21,16 @@ class TestFramework(test_base.TestBase): 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 = self.create_client_from_base(api_version=api_version) + client = openphoto.OpenPhoto(config_file=self.config_file, + api_version=api_version) result = client.get("hello.json") self.assertEqual(result['message'], "Hello, world!") self.assertEqual(result['result']['__route__'], "/v%d/hello.json" % api_version) def test_unspecified_api_version(self): # If the API version is unspecified, we get a generic hello world message - client = self.create_client_from_base(api_version=None) + client = openphoto.OpenPhoto(config_file=self.config_file, + api_version=None) result = client.get("hello.json") self.assertEqual(result['message'], "Hello, world!") self.assertEqual(result['result']['__route__'], "/hello.json") @@ -43,6 +38,7 @@ class TestFramework(test_base.TestBase): def test_future_api_version(self): # If the API version is unsupported, we should get an error # (it's a ValueError, since the returned 404 HTML page is not valid JSON) - client = self.create_client_from_base(api_version=openphoto.LATEST_API_VERSION + 1) + client = openphoto.OpenPhoto(config_file=self.config_file, + api_version=openphoto.LATEST_API_VERSION + 1) with self.assertRaises(openphoto.OpenPhoto404Error): client.get("hello.json")