diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea096a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +/build +/dist +/MANIFEST +/delighted.egg-info +*.pyc +*.egg +*.class + +# Unit test / coverage reports +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5f806eb --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2015 Delighted Inc. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8bf2ad4 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Delighted API Python Client + +Official Python client for the [Delighted API](https://delighted.com/docs/api). + +## Installation + +Add `gem 'delighted'` to your application's Gemfile, and then run `bundle` to install. + +## Configuration + +To get started, you need to configure the client with your secret API key. If you're using Rails, you should add the following to new initializer file in `config/initializers/delighted.rb`. + +```python +require 'delighted' +Delighted.api_key = 'YOUR_API_KEY' +``` + +For further options, read the [advanced configuration section](#advanced-configuration). + +**Note:** Your API key is secret, and you should treat it like a password. You can find your API key in your Delighted account, under *Settings* > *API*. + +## Usage + +Adding/updating people and scheduling surveys: + +```python +# Add a new person, and schedule a survey immediately +person1 = Delighted::Person.create(:email => "foo+test1@delighted.com") + +# Add a new person, and schedule a survey after 1 minute (60 seconds) +person2 = Delighted::Person.create(:email => "foo+test2@delighted.com", + :delay => 60) + +# Add a new person, but do not schedule a survey +person3 = Delighted::Person.create(:email => "foo+test3@delighted.com", + :send => false) + +# Add a new person with full set of attributes, including a custom question +# product name, and schedule a survey with a 30 second delay +person4 = Delighted::Person.create(:email => "foo+test4@delighted.com", + :name => "Joe Bloggs", :properties => { :customer_id => 123, :country => "USA", + :question_product_name => "Apple Genius Bar" }, :delay => 30) + +# Update an existing person (identified by email), adding a name, without +# scheduling a survey +updated_person1 = Delighted::Person.create(:email => "foo+test1@delighted.com", + :name => "James Scott", :send => false) +``` + +Unsubscribing people: + +```python +# Unsubscribe an existing person +Delighted::Unsubscribe.create(:person_email => "foo+test1@delighted.com") +``` + +Deleting pending survey requests + +```python +# Delete all pending (scheduled but unsent) survey requests for a person, by email. +Delighted::SurveyRequest.delete_pending(:person_email => "foo+test1@delighted.com") +``` + +Adding survey responses: + +```python +# Add a survey response, score only +survey_response1 = Delighted::SurveyResponse.create(:person => person1.id, + :score => 10) + +# Add *another* survey response (for the same person), score and comment +survey_response2 = Delighted::SurveyResponse.create(:person => person1.id, + :score => 5, :comment => "Really nice.") +``` + +Retrieving a survey response: + +```python +# Retrieve an existing survey response +survey_response3 = Delighted::SurveyResponse.retrieve('123') +``` + +Updating survey responses: + +```python +# Update a survey response score +survey_response4 = Delighted::SurveyResponse.retrieve('234') +survey_response4.score = 10 +survey_response4.save #=> # + +# Update (or add) survey response properties +survey_response4.person_properties = { :segment => "Online" } +survey_response4.save #=> # + +# Update person who recorded the survey response +survey_response4.person = '321' +survey_response4.save #=> # +``` + +Listing survey responses: + +```python +# List all survey responses, 20 per page, first 2 pages +survey_responses_page1 = Delighted::SurveyResponse.all +survey_responses_page2 = Delighted::SurveyResponse.all(:page => 2) + +# List all survey responses, 20 per page, expanding person object +survey_responses_page1_expanded = Delighted::SurveyResponse.all(:expand => ['person']) +survey_responses_page1_expanded[0].person #=> # + +# List all survey responses, 20 per page, for a specific trend (ID: 123) +survey_responses_page1_trend = Delighted::SurveyResponse.all(:trend => "123") + +# List all survey responses, 20 per page, in reverse chronological order (newest first) +survey_responses_page1_desc = Delighted::SurveyResponse.all(:order => 'desc') + +# List all survey responses, 100 per page, page 5, with a time range +filtered_survey_responses = Delighted::SurveyResponse.all(:page => 5, + :per_page => 100, :since => Time.utc(2013, 10, 01), + :until => Time.utc(2013, 11, 01)) +``` + +Retrieving metrics: + +```python +# Get current metrics, 30-day simple moving average, from most recent response +metrics = Delighted::Metrics.retrieve + +# Get current metrics, 30-day simple moving average, from most recent response, +# for a specific trend (ID: 123) +metrics = Delighted::Metrics.retrieve(:trend => "123") + +# Get metrics, for given range +metrics = Delighted::Metrics.retrieve(:since => Time.utc(2013, 10, 01), + :until => Time.utc(2013, 11, 01)) +``` + +## Advanced configuration & testing + +The following options are configurable for the client: + +```python +Delighted.api_key +Delighted.api_base_url # default: 'https://api.delighted.com/v1' +Delighted.http_adapter # default: Delighted::HTTPAdapter.new +``` + +By default, a shared instance of `Delighted::Client` is created lazily in `Delighted.shared_client`. If you want to create your own client, perhaps for test or if you have multiple API keys, you can: + +```python +# Create an custom client instance, and pass as last argument to resource actions +client = Delighted::Client.new(:api_key => 'API_KEY', + :api_base_url => 'https://api.delighted.com/v1', + :http_adapter => Delighted::HTTPAdapter.new) +metrics_from_custom_client = Delighted::Metrics.retrieve({}, client) + +# Or, you can set Delighted.shared_client yourself +Delighted.shared_client = Delighted::Client.new(:api_key => 'API_KEY', + :api_base_url => 'https://api.delighted.com/v1', + :http_adapter => Delighted::HTTPAdapter.new) +metrics_from_custom_shared_client = Delighted::Metrics.retrieve +``` + +## Supported runtimes + +- Python MRI (1.8.7+) +- JPython (1.8 + 1.9 modes) +- RBX (2.1.1) +- REE (1.8.7-2012.02) + +## Contributing + +1. Fork it +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create new Pull Request diff --git a/delighted/__init__.py b/delighted/__init__.py new file mode 100644 index 0000000..1a1f132 --- /dev/null +++ b/delighted/__init__.py @@ -0,0 +1,27 @@ +__title__ = 'delighted' +__version__ = '0.1.0' +__author__ = 'Robby Colvin' +__license__ = 'MIT' + +api_key = None +api_base_url = 'https://api.delightedapp.com/v1' +api_version = 1 + +from delighted.client import Client + + +singleton_client = None + + +def shared_client(): + global singleton_client + if not singleton_client: + singleton_client = Client(api_key=api_key) + return singleton_client + +### Resources ### + +from delighted.resource import ( # noqa + Metrics) + +metrics = Metrics diff --git a/delighted/client.py b/delighted/client.py new file mode 100644 index 0000000..c01a825 --- /dev/null +++ b/delighted/client.py @@ -0,0 +1,31 @@ +from base64 import b64encode +import json + +import delighted +from delighted.http_adapter import HTTPAdapter + + +class Client(object): + + def __init__(self, api_key=None, api_base_url=delighted.api_base_url, http_adapter=HTTPAdapter()): + if api_key is None: + raise ValueError("You must provide an API key by setting \ + delighted.api_key = '123abc' or passing api_key='abc123' \ + when instantiating Client.") + + self.api_key = api_key + self.api_base_url = api_base_url + self.http_adapter = http_adapter + + def request(self, resource, url, params=None, headers=None): + headers['Authorization'] = 'Basic %s' % (b64encode(delighted.api_key)) + headers['User-Agent'] = "Delighted Python %s" % delighted.VERSION + + url = "%s/%s" % (delighted.api_base_url, resource) + + if method == 'post': + headers['Content-Type'] = 'application/x-www-form-urlencoded' + + data = json.dumps(params) + + return self.http_adapter.request(method, url, headers, data) diff --git a/delighted/errors.py b/delighted/errors.py new file mode 100644 index 0000000..a699675 --- /dev/null +++ b/delighted/errors.py @@ -0,0 +1,34 @@ +class Error(Exception): + """Base error """ + + # def __init__(self, response): + # self.response = response + + # def __repr__(self): + # return "<#{@response.status_code}: #{@response.body}>" + + # __str__ = __repr__ + + +class AuthenticationError(Error): + """401, api auth missing or incorrect.""" + pass + + +class UnsupportedFormatRequestedError(Error): + """406, invalid format in Accept header.""" + + +class ResourceValidationError(Error): + """422, validation errors.""" + pass + + +class GeneralAPIError(Error): + """500, general/unknown error.""" + pass + + +class ServiceUnavailableError(Error): + """503, maintenance or overloaded.""" + pass diff --git a/delighted/http_adapter.py b/delighted/http_adapter.py new file mode 100644 index 0000000..9bbe499 --- /dev/null +++ b/delighted/http_adapter.py @@ -0,0 +1,10 @@ +import requests + + +class HTTPAdapter(object): + """Wraps the logic around HTTP.""" + + def request(self, method, uri, headers={}, data=None): + response = requests.request(method, uri, headers=headers, data=data) + + return response.json() diff --git a/delighted/resource.py b/delighted/resource.py new file mode 100644 index 0000000..9f2dda6 --- /dev/null +++ b/delighted/resource.py @@ -0,0 +1,40 @@ +from delighted import shared_client + + +class Resource(object): + """Resource""" + + @classmethod + def class_url(cls): + return "/v1/%s" % (cls.path,) + + +class CreateableResource(Resource): + + @classmethod + def create(cls, api_key=None, idempotency_key=None, **params): + url = cls.class_url() + headers = {} + + return shared_client().request('post', url, params, headers) + + +class UpdatableResource(Resource): + def save(self): + pass + + +class DeletableResource(Resource): + def delete(self): + pass + + +class Metrics(UpdatableResource, DeletableResource): + path = '/metrics' + + @classmethod + def retrieve(cls, **params): + headers = {} + response = shared_client().request('get', cls.path, params, headers) + + return response diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bd8ec93 --- /dev/null +++ b/setup.py @@ -0,0 +1,46 @@ +import re + +try: + from setuptools import setup +except ImportError: + from distutils.core import setup + +version = '' +with open('delighted/__init__.py', 'r') as fd: + version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), + re.MULTILINE).group(1) +if not version: + raise RuntimeError('Cannot find version information') + +setup( + name='delighted', + version=version, + description='Delighted API Python Client.', + long_description='Delighted is the fastest and easiest way to gather actionable feedback from your customers.', + author='Robby Colvin', + author_email='geetarista@gmail.com', + url='https://delighted.com/', + packages=['delighted'], + package_data={'delighted': ['../VERSION']}, + package_dir={'delighted': 'delighted'}, + test_suite="test", + include_package_data=True, + install_requires=['requests'], + license='MIT', + zip_safe=False, + classifiers=( + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', "License :: OSI Approved :: MIT License", + 'Natural Language :: English', "Operating System :: OS Independent", + 'Programming Language :: Python', "Programming Language :: Python :: 2", + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + "Programming Language :: Python :: 3.2", + 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4' + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries :: Python Modules" + ), + extras_require={'security': ['pyOpenSSL', 'ndg-httpsclient', 'pyasn1'], }, + # use_2to3=True, +) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..0472b7f --- /dev/null +++ b/test/__init__.py @@ -0,0 +1,32 @@ +import unittest + +from mock import Mock, patch + + +class DelightedTestCase(unittest.TestCase): + + def setUp(self): + super(DelightedTestCase, self).setUp() + + self.request_patcher = patch('delighted.http_adapter.requests.request') + self.request_mock = self.request_patcher.start() + + def tearDown(self): + super(DelightedTestCase, self).tearDown() + + self.request_patcher.stop() + + def mock_response(self, data): + mock_response = Mock() + mock_response.json.return_value = data + + self.request_mock.return_value = mock_response + + def mock_error(self, mock): + mock.exceptions.RequestException = Exception + mock.request.side_effect = mock.exceptions.RequestException() + + def check_call(self, meth, url, post_data, headers): + self.request_mock.assert_called_once_with(meth, url, + headers=headers, + data=post_data) diff --git a/test/test_client.py b/test/test_client.py new file mode 100644 index 0000000..04cb897 --- /dev/null +++ b/test/test_client.py @@ -0,0 +1,10 @@ +import unittest + +from delighted import Client + + +class ClientTest(unittest.TestCase): + + def test_api_key_required(self): + with self.assertRaises(ValueError): + Client() diff --git a/test/test_resource.py b/test/test_resource.py new file mode 100644 index 0000000..9364b02 --- /dev/null +++ b/test/test_resource.py @@ -0,0 +1,18 @@ +import delighted +from . import DelightedTestCase + + +class TestResource(DelightedTestCase): + + def test_retreive_metrics(self): + data = {'nps': 10} + headers = {'Authorization': 'Basic YWJjMTIz', 'User-Agent': 'pytest'} + url = 'https://api.delightedapp.com/v1/metrics' + delighted.api_key = 'abc123' + self.mock_response(data) + + metrics = delighted.metrics.retrieve() + assert metrics == data + self.check_call('get', url, '{}', headers) + with self.assertRaises(AttributeError): + metrics.id diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..659007d --- /dev/null +++ b/tox.ini @@ -0,0 +1,12 @@ +[tox] +# envlist = py26, py27, pypy + +[testenv] +commands = python -W always setup.py test {posargs} +deps = + requests + mock + +# [testenv:py27] +# commands = +# flake8 delighted