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")