diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7d054a1 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +#### Background context for this PR + +#### What does this PR do? +- [x] Something that's complete +- [ ] Something that's still in progress + +#### Impacted areas in application +- A page affected +- A library affected + +#### How should PR reviewers test this? +- Unit tests: +- Manual tests: + - What features need to be on or off? + - Detailed description of how to reproduce + - ...what page + - ...test data + - ...etc + +#### Would you like specific feedback on anything? diff --git a/setup.py b/setup.py index 16db589..e47ab38 100644 --- a/setup.py +++ b/setup.py @@ -3,25 +3,27 @@ from setuptools import setup with open('wordsmith/__init__.py', 'r') as fd: - version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE).group(1) + version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', + fd.read(), + re.MULTILINE).group(1) if not version: - raise RuntimeError('Could not locate version information') + raise RuntimeError('Could not locate version information') setup( - name = 'wordsmith', - version = version, - description = 'A wrapper around the Wordsmith API written in python', - author = 'John Hegele - Automated Insights', - packages = ['wordsmith'], - package_dir = {'wordsmith' : 'wordsmith'}, - install_requires = ['requests', 'six'], - classifiers = [ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'Natural Language :: English', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.5' - ] + name='wordsmith', + version=version, + description='A wrapper around the Wordsmith API written in python', + author='John Hegele - Automated Insights', + packages=['wordsmith'], + package_dir={'wordsmith': 'wordsmith'}, + install_requires=['requests', 'six'], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.6' + ] ) diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..67c8c10 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,9 @@ +from wordsmith import Wordsmith + +API_KEY = '923b278a6088675262af64ceb437bab31d7ebc6b07aaf89f88b0b88dd4fe2a97' + + +class TestWordsmith(object): + + def initialize(self, **kwargs): + return Wordsmith(API_KEY, user_agent='python sdk test suite', **kwargs) diff --git a/tests/test_batch_generate.py b/tests/test_batch_generate.py new file mode 100644 index 0000000..da14b3b --- /dev/null +++ b/tests/test_batch_generate.py @@ -0,0 +1,54 @@ +import pytest +from wordsmith import NarrativeGenerateError +from tests.fixtures import TestWordsmith + + +class TestProject(TestWordsmith): + + def setup(self): + self.ws = super().initialize() + + def test_batch_generate_narrative(self): + data = [{'a': i, 'b': i, 'c': i} for i in range(10)] + expected_outputs = ['The value of A is {}.'.format(i) + for i in range(10)] + narrs = self.ws.project('test').template('test').batch_narrative(data) + narrs.generate() + for expected, actual in zip(expected_outputs, narrs.narratives): + assert expected == actual.text + + def test_bad_batch_generate_no_break(self): + data = [ + {'a': 1, 'b': 1, 'c': 1}, + {'d': 1, 'e': 1}, + {'a': 1, 'b': 1, 'c': 1} + ] + narrs = self.ws.project('test').template('test').batch_narrative(data) + narrs.break_on_error = False + narrs.generate() + expected_narratives = ['The value of A is 1.', + None, + 'The value of A is 1.'] + actual_narratives = [] + for n in narrs.narratives: + actual_narratives.append(n.text if n is not None else None) + assert (expected_narratives == actual_narratives)\ + and (len(narrs.errors) == 1) + + def test_bad_batch_generate_break(self): + with pytest.raises(NarrativeGenerateError): + data = [ + {'a': 1, 'b': 1, 'c': 1}, + {'d': 1, 'e': 1}, + {'a': 1, 'b': 1, 'c': 1} + ] + narrs = self.ws.project('test')\ + .template('test').batch_narrative(data) + narrs.break_on_error = True + narrs.generate() + + def test_wordsmith_400_error(self): + with pytest.raises(NarrativeGenerateError): + data = {'not_a_valid_column': 0} + self.ws.project('test')\ + .template('test').generate_narrative(data) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..df95a2c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,22 @@ +from wordsmith import Wordsmith +from tests.fixtures import TestWordsmith + + +class TestConfig(TestWordsmith): + + def setup(self): + self.ws = super().initialize() + + def test_set_token_value(self): + token = 'my_token' + ws = Wordsmith(token) + assert ws.config.api_key == token + + def test_default_url(self): + assert self.ws.config.base_url \ + == 'https://api.automatedinsights.com/v1' + + def test_set_new_version(self): + new_version = 'a_new_version' + ws = Wordsmith('my_token', version=new_version) + assert ws.config.version == new_version diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..3ebb1f4 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,43 @@ +import pytest +from wordsmith import Wordsmith, ProjectSlugError +from tests.fixtures import TestWordsmith + + +class TestProject(TestWordsmith): + + def setup(self): + self.ws = super().initialize() + + def test_list_all_projects(self): + projects = [project.name for project in self.ws.projects] + assert projects == ['Test'] + + def test_list_all_projects_with_custom_base_url(self): + ws = Wordsmith(self.ws.config.api_key, + user_agent='python sdk test suite', + base_url='https://api.automatedinsights.com/v1') + projects = [project.name for project in ws.projects] + assert projects == ['Test'] + + def test_find_project_by_slug(self): + project = self.ws.project('test') + assert project.name == 'Test' + + def test_bad_project_raises_error(self): + with pytest.raises(ProjectSlugError): + self.ws.project('fake project') + + ''' + TODO - these tests from ruby version + def test_schema + project = Wordsmith::Project.find 'test' + expected = {a: 'Number', b: 'Number', c: 'Number'} + assert_equal expected, project.schema + end + + def test_template_collection_exists + project = Wordsmith::Project.find('test') + assert_instance_of Wordsmith::TemplateCollection, project.templates + end + end + ''' diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 0000000..b298584 --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,31 @@ +import pytest +from wordsmith import TemplateSlugError +from tests.fixtures import TestWordsmith + + +class TestProject(TestWordsmith): + + def setup(self): + self.ws = super().initialize() + + def test_bad_template_raises_error(self): + with pytest.raises(TemplateSlugError): + self.ws.project('test').template('fake template') + + def test_find_project_by_name(self): + matches = self.ws.find_project('Test') + assert matches[0].name == 'Test' + + def test_find_template_that_exists(self): + matches = self.ws.project('test').find_template('Test') + assert len(matches) > 0 + + def test_find_template_that_doesnt_exist(self): + matches = self.ws.project('test').find_template('Fake Template') + assert len(matches) == 0 + + def test_generate_narrative(self): + data = {'a': 1, 'b': 1, 'c': 1} + narr = self.ws.project('test')\ + .template('test').generate_narrative(data).text + assert narr == 'The value of A is 1.' diff --git a/tests/test_wordsmith.py b/tests/test_wordsmith.py deleted file mode 100644 index cb7685f..0000000 --- a/tests/test_wordsmith.py +++ /dev/null @@ -1,103 +0,0 @@ -import pytest -from wordsmith import Wordsmith, ProjectSlugError, TemplateSlugError, NarrativeGenerateError - -API_KEY = '923b278a6088675262af64ceb437bab31d7ebc6b07aaf89f88b0b88dd4fe2a97' - -class TestWordsmith(object): - - def setup(self): - self.ws = Wordsmith(API_KEY, user_agent='python sdk test suite') - - def test_config_specifying_base_url(self): - ws = Wordsmith(API_KEY, - user_agent='python sdk test suite', - base_url='https://api.automatedinsights.com/v1') - - - def test_list_all_projects(self): - #ws = Wordsmith(API_KEY) - projects = [project.name for project in self.ws.projects] - assert projects == ['Real Estate', - 'Ecommerce', - 'Business Intelligence', - 'Tutorial', - 'Test'] - - def test_list_all_projects_with_custom_base_url(self): - ws = Wordsmith(API_KEY, - user_agent='python sdk test suite', - base_url='https://api.automatedinsights.com/v1') - projects = [project.name for project in ws.projects] - assert projects == ['Real Estate', - 'Ecommerce', - 'Business Intelligence', - 'Tutorial', - 'Test'] - - def test_find_project_by_slug(self): - project = self.ws.project('test') - assert project.name == 'Test' - - def test_bad_project_raises_error(self): - with pytest.raises(ProjectSlugError): - project = self.ws.project('fake project') - - def test_bad_template_raises_error(self): - with pytest.raises(TemplateSlugError): - template = self.ws.project('test').template('fake template') - - def test_find_project_by_name(self): - matches = self.ws.find_project('Real Estate') - assert matches[0].name == 'Real Estate' - - def test_find_template_that_exists(self): - matches = self.ws.project('test').find_template('Test') - assert len(matches) > 0 - - def test_find_template_that_doesnt_exist(self): - matches = self.ws.project('test').find_template('Fake Template') - assert len(matches) == 0 - - def test_generate_narrative(self): - data = {'a': 1, 'b': 1, 'c': 1} - narr = self.ws.project('test').template('test').generate_narrative(data).text - assert narr == 'The value of A is 1.' - - def test_batch_generate_narrative(self): - data = [{'a': i, 'b': i, 'c': i} for i in range(10)] - expected_outputs = ['The value of A is {}.'.format(i) for i in range(10)] - narrs = self.ws.project('test').template('test').batch_narrative(data) - narrs.generate() - for expected, actual in zip(expected_outputs, narrs.narratives): - assert expected == actual.text - - def test_bad_batch_generate_no_break(self): - data = [ - {'a': 1, 'b': 1, 'c': 1}, - {'d': 1, 'e': 1}, - {'a': 1, 'b': 1, 'c': 1} - ] - narrs = self.ws.project('test').template('test').batch_narrative(data) - narrs.break_on_error = False - narrs.generate() - expected_narratives = ['The value of A is 1.', None, 'The value of A is 1.'] - actual_narratives = [] - for n in narrs.narratives: - actual_narratives.append(n.text if n is not None else None) - assert (expected_narratives == actual_narratives) and (len(narrs.errors) == 1) - - def test_bad_batch_generate_break(self): - with pytest.raises(NarrativeGenerateError): - data = [ - {'a': 1, 'b': 1, 'c': 1}, - {'d': 1, 'e': 1}, - {'a': 1, 'b': 1, 'c': 1} - ] - narrs = self.ws.project('test').template('test').batch_narrative(data) - narrs.break_on_error = True - narrs.generate() - - def test_wordsmith_400_error(self): - with pytest.raises(NarrativeGenerateError): - data = {'not_a_valid_column': 0} - narr = self.ws.project('test').template('test').generate_narrative(data) diff --git a/wordsmith/__init__.py b/wordsmith/__init__.py index 5499376..1fcbaea 100644 --- a/wordsmith/__init__.py +++ b/wordsmith/__init__.py @@ -1,11 +1,14 @@ -__title__ = 'wordsmith' -__version__ = '0.5' -__author__ = 'John Hegele' -__copyright__ = 'Copyright 2016 Automated Insights' - +# flake8: noqa: F401 from .wordsmith import Wordsmith from .configuration import Configuration from .project import Project from .template import Template from .narrative import Narrative, Batch -from .exceptions import (ProjectSlugError, TemplateSlugError, NarrativeGenerateError) +from .exceptions import (ProjectSlugError, + TemplateSlugError, + NarrativeGenerateError) + +__title__ = 'wordsmith' +__version__ = '0.5.1' +__author__ = 'John Hegele' +__copyright__ = 'Copyright 2016 Automated Insights' diff --git a/wordsmith/configuration.py b/wordsmith/configuration.py index 91414e1..f3370b9 100644 --- a/wordsmith/configuration.py +++ b/wordsmith/configuration.py @@ -4,30 +4,40 @@ This module implements the Wordsmith configuration object. """ -from . import __version__ -class Configuration(object): - """ - Constructs a :class:`Wordsmith ` object. - :param api_key: API key from Wordsmith. - :param base_url: (optional) String representing the base URL for the Wordsmith API per documentation at http://wordsmith.readme.io/v1/docs - :param user_agent: (optional) String representing the user agent that should be sent with each API request +DEFAULT_VERSION = '1' +DEFAULT_URL = 'https://api.automatedinsights.com/v' + DEFAULT_VERSION +DEFAULT_USER_AGENT = 'PythonSDK' + + +class Configuration(object): """ + Constructs a :class:`Wordsmith ` object. - DEFAULT_URL = 'https://api.automatedinsights.com/v1' - DEFAULT_USER_AGENT = 'PythonSDK/' + __version__ + :param api_key: API key from Wordsmith. + :param base_url: (optional) String representing the base URL for the + Wordsmith API per documentation at + http://wordsmith.readme.io/v1/docs + :param user_agent: (optional) String representing the user agent that + should be sent with each API request + """ - def __init__(self, api_key, **kwargs): - self.api_key = api_key - self.base_url = kwargs['base_url'] if 'base_url' in kwargs else self.DEFAULT_URL - self.user_agent = kwargs['user_agent'] if 'user_agent' in kwargs else self.DEFAULT_USER_AGENT + def __init__(self, api_key, **kwargs): + self.api_key = api_key + self.base_url = kwargs['base_url'] if 'base_url' in kwargs\ + else self.DEFAULT_URL + self.user_agent = kwargs['user_agent'] if 'user_agent' in kwargs\ + else self.DEFAULT_USER_AGENT + self.version = kwargs['version'] if 'version' in kwargs\ + else self.DEFAULT_VERSION - def get_headers(self): - """ - Format user agent and auth values as a dictionary for use in GET/POST requests to the API - """ - return { - 'User-Agent' : self.user_agent, - 'Authorization' : 'Bearer ' + self.api_key - } + def get_headers(self): + """ + Format user agent and auth values as a dictionary for use in GET/POST + requests to the API + """ + return { + 'User-Agent': self.user_agent, + 'Authorization': 'Bearer ' + self.api_key + } diff --git a/wordsmith/exceptions.py b/wordsmith/exceptions.py index 40937f2..30ae05b 100644 --- a/wordsmith/exceptions.py +++ b/wordsmith/exceptions.py @@ -5,34 +5,41 @@ This module contains Wordsmith's custom exceptions. """ + class ProjectSlugError(ValueError): - """An invalid project slug was passed.""" + """An invalid project slug was passed.""" + + def __init__(self, msg): + self.msg = msg - def __init__(self, msg): - self.msg = msg class TemplateSlugError(ValueError): - """An invalid template slug was passed.""" + """An invalid template slug was passed.""" + + def __init__(self, msg): + self.msg = msg - def __init__(self, msg): - self.msg = msg class NarrativeGenerateError(Exception): - """The Wordsmith platform responded with an error code when attempting to generate narrative.""" - - def __init__(self, response, data): - """Initialize with the HTTP response object""" - self.http_status_code = response.status_code - self.http_reason = response.reason - self.data = data - try: - self.details = [str(e['detail']) for e in response.json()['errors']] - self._details_reported = True - except KeyError: - self.details = ['Wordsmith reported an error but no details were provided.'] - self._details_reported = False - self.msg = '\nError generating narrative.' + \ - '\nHTTP Status Code: {}'.format(self.http_status_code) + \ - '\nHTTP Reason: {}'.format(self.http_reason) + \ - '\nNumber of errors reported by Wordsmith: {}'.format(len(self.details) if self._details_reported else 0) - super(NarrativeGenerateError, self).__init__(self.msg) + """The Wordsmith platform responded with an error code when attempting to + generate narrative.""" + + def __init__(self, response, data): + """Initialize with the HTTP response object""" + self.http_status_code = response.status_code + self.http_reason = response.reason + self.data = data + try: + self.details = [str(e['detail']) + for e in response.json()['errors']] + self._details_reported = True + except KeyError: + self.details =\ + ['Wordsmith reported an error but no details were provided.'] + self._details_reported = False + self.msg = '\nError generating narrative.' + \ + '\nHTTP Status Code: {}'.format(self.http_status_code) + \ + '\nHTTP Reason: {}'.format(self.http_reason) + \ + '\nNumber of errors reported by Wordsmith: {}'.\ + format(len(self.details) if self._details_reported else 0) + super(NarrativeGenerateError, self).__init__(self.msg) diff --git a/wordsmith/narrative.py b/wordsmith/narrative.py index 9a954fb..8797f51 100644 --- a/wordsmith/narrative.py +++ b/wordsmith/narrative.py @@ -11,74 +11,93 @@ from multiprocessing.dummy import Pool from .exceptions import NarrativeGenerateError + class Narrative(object): - """ - Constructs a :class:`Wordsmith ` object. + """ + Constructs a :class:`Wordsmith ` object. - :param project_slug: String representing the slug of the parent project of this narrative - :param template_slug: String representing the slug of the parent template of this narrative - :param data: Dictionary representation of the row data to be passed to the Wordsmith platform - :param config: wordsmith.Configuration object containing configuration details - """ + :param project_slug: String representing the slug of the parent project of + this narrative + :param template_slug: String representing the slug of the parent template + of this narrative + :param data: Dictionary representation of the row data to be passed to the + Wordsmith platform + :param config: wordsmith.Configuration object containing configuration + details + """ - def __init__(self, project_slug, template_slug, data, config): - self.project_slug = project_slug - self.template_slug = template_slug - self.data = data - self._config = config - self.post_url = '{}/projects/{}/templates/{}/outputs'.format(self._config.base_url, self.project_slug, self.template_slug) - headers = self._config.get_headers() - headers['Content-Type'] = 'application/json' - for header, value in six.iteritems(data): - if value is None: - data[header] = '' - ws_data = { - 'data' : data - } - response = requests.post(self.post_url, data=json.dumps(ws_data), headers=headers) - if response.status_code == 200: - self.text = json.loads(response.text)['data']['content'] - else: - self.text = None - raise NarrativeGenerateError(response, data) + def __init__(self, project_slug, template_slug, data, config): + self.project_slug = project_slug + self.template_slug = template_slug + self.data = data + self._config = config + self.post_url = '{}/projects/{}/templates/{}/outputs'\ + .format(self._config.base_url, + self.project_slug, + self.template_slug) + headers = self._config.get_headers() + headers['Content-Type'] = 'application/json' + for header, value in six.iteritems(data): + if value is None: + data[header] = '' + ws_data = { + 'data': data + } + response = requests.post(self.post_url, + data=json.dumps(ws_data), + headers=headers) + if response.status_code == 200: + self.text = json.loads(response.text)['data']['content'] + else: + self.text = None + raise NarrativeGenerateError(response, data) def __str__(self): - return self.text + return self.text + class Batch(object): - """ - Constructs a :class:`Wordsmith ` object. + """ + Constructs a :class:`Wordsmith ` object. - :param project_slug: String representing the slug of the parent project of this narrative - :param template_slug: String representing the slug of the parent template of this narrative - :param data_list: List of dictionary representations of the row data to be passed to the Wordsmith platform - :param config: wordsmith.Configuration object containing configuration details - """ + :param project_slug: String representing the slug of the parent project of + this narrative + :param template_slug: String representing the slug of the parent template + of this narrative + :param data_list: List of dictionary representations of the row data to be + passed to the Wordsmith platform + :param config: wordsmith.Configuration object containing configuration + details + """ - def __init__(self, project_slug, template_slug, data_list, config): - self.project_slug = project_slug - self.template_slug = template_slug - self.data_list = data_list - self._config = config - self.break_on_error = False - self.pool_size = 8 - self.narratives = [] - self.errors = [] + def __init__(self, project_slug, template_slug, data_list, config): + self.project_slug = project_slug + self.template_slug = template_slug + self.data_list = data_list + self._config = config + self.break_on_error = False + self.pool_size = 8 + self.narratives = [] + self.errors = [] - def _generate_narrative(self, data): - narrative = None - try: - narrative = Narrative(self.project_slug, self.template_slug, data, self._config) - except NarrativeGenerateError as e: - if self.break_on_error: - raise e - else: - self.errors.append(e) - return narrative + def _generate_narrative(self, data): + narrative = None + try: + narrative = Narrative(self.project_slug, + self.template_slug, + data, + self._config) + except NarrativeGenerateError as e: + if self.break_on_error: + raise e + else: + self.errors.append(e) + return narrative - def generate(self): - self._index = 0 - thread_pool = Pool(self.pool_size) - self.narratives = thread_pool.map(self._generate_narrative, self.data_list) - thread_pool.close() - thread_pool.join() + def generate(self): + self._index = 0 + thread_pool = Pool(self.pool_size) + self.narratives = thread_pool.map(self._generate_narrative, + self.data_list) + thread_pool.close() + thread_pool.join() diff --git a/wordsmith/project.py b/wordsmith/project.py index 937396c..85ae271 100644 --- a/wordsmith/project.py +++ b/wordsmith/project.py @@ -7,37 +7,44 @@ from .template import Template from .exceptions import TemplateSlugError + class Project(object): - def __init__(self, name, slug, schema, templates, config): - self._config = config - self.templates = [] - self.name = name - self.slug = slug - self.schema = schema - for template_data in templates: - self.templates.append(Template(self.slug, template_data['name'], template_data['slug'], self._config)) - - def template(self, slug): - """ - Get a Wordsmith template by slug - - :param slug: String representing the slug of the Wordmsith template - :return: :class:`Wordsmith