Merge branch 'master' into apiv1_fixes

Conflicts:
	tests/test_base.py
	tests/test_framework.py
This commit is contained in:
sneakypete81 2013-05-11 15:01:48 +01:00
commit c76ad3f181
8 changed files with 257 additions and 142 deletions

View file

@ -8,38 +8,56 @@ Open Photo API / Python Library
python setup.py install python setup.py install
---------------------------------------- ----------------------------------------
<a name="credentials"></a>
### 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
----------------------------------------
<a name="python"></a> <a name="python"></a>
### How to use the library ### 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: You can use the library in one of two ways:
* Direct GET/POST calls to the server * Direct GET/POST calls to the server
* Access via Python classes/methods * Access via Python classes/methods
<a name="get_post"></a> <a name="get_post"></a>
### Direct GET/POST: #### Direct GET/POST:
from openphoto import OpenPhoto from openphoto import OpenPhoto
client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret) client = OpenPhoto()
resp = client.get("/photos/list.json") resp = client.get("/photos/list.json")
resp = client.post("/photo/62/update.json", tags=["tag1", "tag2"]) resp = client.post("/photo/62/update.json", tags=["tag1", "tag2"])
<a name="python_classes"></a> <a name="python_classes"></a>
### Python classes/methods #### Python classes/methods
from openphoto import OpenPhoto from openphoto import OpenPhoto
client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret) client = OpenPhoto()
photos = client.photos.list() photos = client.photos.list()
photos[0].update(tags=["tag1", "tag2"]) photos[0].update(tags=["tag1", "tag2"])
print photos[0].tags 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: 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 * ``client.photos.list() -> /photos/list.json``
* photos[0].update() -> /photo/&lt;id&gt;/update.json * ``photos[0].update() -> /photo/<id>/update.json``
<a name="api_versioning"></a> <a name="api_versioning"></a>
### API Versioning ### 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: To do this, add the optional ```api_version``` parameter when creating the client object:
from openphoto import OpenPhoto from openphoto import OpenPhoto
client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret, api_version=2) client = OpenPhoto(api_version=2)
---------------------------------------- ----------------------------------------
<a name="cli"></a> <a name="cli"></a>
### Using from the command line ### Using from the command line
When using the command line tool, you'll want to export your authentication credentials to the environment. You can run commands to the OpenPhoto API from your shell!
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
<a href="#credentials">Click here for instructions on getting credentials</a>.
These are the options you can pass to the shell program: These are the options you can pass to the shell program:
-h # Display help text -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 -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] -e endpoint # [default=/photos/list.json]
-X method # [default=GET] -X method # [default=GET]
-F params # e.g. -F 'title=my title' -F 'tags=mytag1,mytag2' -F params # e.g. -F 'title=my title' -F 'tags=mytag1,mytag2'
-p # Pretty print the json -p # Pretty print the json
-v # Verbose output -v # Verbose output
You can run commands to the OpenPhoto API from your shell! <a name="cli-examples"></a>
#### Command line examples
# Upload a public photo to the host specified in ~/.config/openphoto/default # 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' 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!
... ...
} }
} }
<a name="credentials"></a>
#### 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.

View file

@ -8,18 +8,21 @@ LATEST_API_VERSION = 2
class OpenPhoto(OpenPhotoHttp): class OpenPhoto(OpenPhotoHttp):
""" """
Python client library for the specified OpenPhoto host. 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. OAuth tokens (consumer*, token*) can optionally be specified.
All requests will include the api_version path, if specified. All requests will include the api_version path, if specified.
This should be used to ensure that your application will continue to work 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='', consumer_key='', consumer_secret='',
token='', token_secret='', token='', token_secret='',
api_version=None): api_version=None):
OpenPhotoHttp.__init__(self, host, OpenPhotoHttp.__init__(self, config_file, host,
consumer_key, consumer_secret, consumer_key, consumer_secret,
token, token_secret, api_version) token, token_secret, api_version)

View file

@ -3,8 +3,6 @@ import os
import sys import sys
import string import string
import urllib import urllib
import StringIO
import ConfigParser
from optparse import OptionParser from optparse import OptionParser
try: try:
@ -14,47 +12,6 @@ except ImportError:
from openphoto import OpenPhoto 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:]): def main(args=sys.argv[1:]):
@ -85,16 +42,28 @@ def main(args=sys.argv[1:]):
# Host option overrides config file settings # Host option overrides config file settings
if options.host: if options.host:
config = {'host': options.host, 'consumerKey': '', 'consumerSecret': '', client = OpenPhoto(host=options.host)
'token': '', 'tokenSecret': ''}
else: else:
config_path = get_config_path(options.config_file) try:
config = read_config(config_path) client = OpenPhoto(config_file=options.config_file)
if options.verbose: except IOError as error:
print "Using config from '%s'" % config_path print error
print
client = OpenPhoto(config['host'], config['consumerKey'], config['consumerSecret'], print "You must create a configuration file with the following contents:"
config['token'], config['tokenSecret']) 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": if options.method == "GET":
result = client.get(options.endpoint, process_response=False, **params) result = client.get(options.endpoint, process_response=False, **params)

View file

@ -1,8 +1,11 @@
import os
import oauth2 as oauth import oauth2 as oauth
import urlparse import urlparse
import urllib import urllib
import httplib2 import httplib2
import logging import logging
import StringIO
import ConfigParser
try: try:
import json import json
except ImportError: except ImportError:
@ -16,17 +19,41 @@ DUPLICATE_RESPONSE = {"code": 409,
"message": "This photo already exists"} "message": "This photo already exists"}
class OpenPhotoHttp: 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): token='', token_secret='', api_version=None):
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._host = host
self._consumer_key = consumer_key self._consumer_key = consumer_key
self._consumer_secret = consumer_secret self._consumer_secret = consumer_secret
self._token = token self._token = token
self._token_secret = token_secret self._token_secret = token_secret
self._api_version = api_version
self._logger = logging.getLogger("openphoto") 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 # Remember the most recent HTTP request and response
self.last_url = None self.last_url = None
@ -205,3 +232,42 @@ class OpenPhotoHttp:
return [] return []
else: else:
return result 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)

View file

@ -5,23 +5,27 @@ Tests for the Open Photo API / Python Library
---------------------------------------- ----------------------------------------
<a name="requirements"></a> <a name="requirements"></a>
### Requirements ### Requirements
A computer, Python 2.7 and an empty OpenPhoto instance. A computer, Python 2.7 and an empty OpenPhoto test host.
--------------------------------------- ---------------------------------------
<a name="setup"></a> <a name="setup"></a>
### Setting up ### Setting up
Create a tests/tokens.py file containing the following: Create a ``~/.config/openphoto/test`` config file containing the following:
# tests/tokens.py # ~/.config/openphoto/test
consumer_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" host = your.host.com
consumer_secret = "xxxxxxxxxx" consumerKey = your_consumer_key
token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" consumerSecret = your_consumer_secret
token_secret = "xxxxxxxxxx" token = your_access_token
host = "your_hostname" tokenSecret = your_access_token_secret
Make sure this is an empty test server, **not a production OpenPhoto server!!!** 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
--------------------------------------- ---------------------------------------
<a name="running"></a> <a name="running"></a>
### Running the tests ### Running the tests

View file

@ -1,22 +1,8 @@
import unittest
import os import os
import unittest
import logging import logging
import openphoto import openphoto
try:
import tokens
except ImportError:
print ("********************************************************************\n"
"You need to create a 'tokens.py' file containing the following:\n\n"
" host = \"<hostname>\"\n"
" consumer_key = \"<test_consumer_key>\"\n"
" consumer_secret = \"<test_consumer_secret>\"\n"
" token = \"<test_token>\"\n"
" token_secret = \"<test_token_secret>\"\n"
"WARNING: Don't use a production OpenPhoto instance for this!\n"
"********************************************************************\n")
raise
def get_test_server_api(): def get_test_server_api():
return int(os.getenv("OPENPHOTO_TEST_SERVER_API", openphoto.LATEST_API_VERSION)) 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 MAXIMUM_TEST_PHOTOS = 4 # Never have more the 4 photos on the test server
testcase_name = "(unknown testcase)" testcase_name = "(unknown testcase)"
api_version = None api_version = None
config_file = os.getenv("OPENPHOTO_TEST_CONFIG", "test")
def __init__(self, *args, **kwds): def __init__(self, *args, **kwds):
unittest.TestCase.__init__(self, *args, **kwds) unittest.TestCase.__init__(self, *args, **kwds)
@ -45,25 +32,23 @@ class TestBase(unittest.TestCase):
else: else:
print "\nTesting %s v%d" % (cls.testcase_name, cls.api_version) print "\nTesting %s v%d" % (cls.testcase_name, cls.api_version)
cls.client = openphoto.OpenPhoto(tokens.host, cls.client = openphoto.OpenPhoto(config_file=cls.config_file,
tokens.consumer_key, tokens.consumer_secret, api_version=cls.api_version)
tokens.token, tokens.token_secret,
cls.api_version)
if cls.client.photos.list() != []: if cls.client.photos.list() != []:
raise ValueError("The test server (%s) contains photos. " raise ValueError("The test server (%s) contains photos. "
"Please delete them before running the tests" "Please delete them before running the tests"
% tokens.host) % cls.client._host)
if cls.client.tags.list() != []: if cls.client.tags.list() != []:
raise ValueError("The test server (%s) contains tags. " raise ValueError("The test server (%s) contains tags. "
"Please delete them before running the tests" "Please delete them before running the tests"
% tokens.host) % cls.client._host)
if cls.client.albums.list() != []: if cls.client.albums.list() != []:
raise ValueError("The test server (%s) contains albums. " raise ValueError("The test server (%s) contains albums. "
"Please delete them before running the tests" "Please delete them before running the tests"
% tokens.host) % cls.client._host)
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):

90
tests/test_config.py Normal file
View file

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

View file

@ -10,17 +10,10 @@ class TestFramework(test_base.TestBase):
"""Override the default setUp, since we don't need a populated database""" """Override the default setUp, since we don't need a populated database"""
logging.info("\nRunning %s..." % self.id()) 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): def test_api_version_zero(self):
# API v0 has a special hello world message # 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") result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world! This is version zero of the API!") self.assertEqual(result['message'], "Hello, world! This is version zero of the API!")
self.assertEqual(result['result']['__route__'], "/v0/hello.json") self.assertEqual(result['result']['__route__'], "/v0/hello.json")
@ -28,14 +21,16 @@ class TestFramework(test_base.TestBase):
def test_specified_api_version(self): def test_specified_api_version(self):
# For all API versions >0, we get a generic hello world message # 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): 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") result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world!") self.assertEqual(result['message'], "Hello, world!")
self.assertEqual(result['result']['__route__'], "/v%d/hello.json" % api_version) self.assertEqual(result['result']['__route__'], "/v%d/hello.json" % api_version)
def test_unspecified_api_version(self): def test_unspecified_api_version(self):
# If the API version is unspecified, we get a generic hello world message # 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") result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world!") self.assertEqual(result['message'], "Hello, world!")
self.assertEqual(result['result']['__route__'], "/hello.json") self.assertEqual(result['result']['__route__'], "/hello.json")
@ -43,6 +38,7 @@ class TestFramework(test_base.TestBase):
def test_future_api_version(self): def test_future_api_version(self):
# If the API version is unsupported, we should get an error # 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) # (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): with self.assertRaises(openphoto.OpenPhoto404Error):
client.get("hello.json") client.get("hello.json")