Skip to content

Commit

Permalink
Refactor SlackApi to use core functionality from flash_services.
Browse files Browse the repository at this point in the history
  • Loading branch information
textbook committed Oct 12, 2016
1 parent d927c03 commit ead5f9b
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 120 deletions.
2 changes: 1 addition & 1 deletion aslack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
logging.getLogger(__name__).addHandler(logging.NullHandler())

__author__ = 'Jonathan Sharpe'
__version__ = '0.8.4'
__version__ = '0.9.0'
105 changes: 105 additions & 0 deletions aslack/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Core API wrapper functionality, adapted from `Flash Services`_.
.. _Flash Services:
https://pypi.python.org/pypi/flash-services
"""
# pylint: disable=too-few-public-methods
from abc import ABCMeta, abstractmethod
from collections import OrderedDict
from os import getenv
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit


class Service(metaclass=ABCMeta):
"""Abstract base class for API wrapper services."""

REQUIRED = set()
""":py:class:`set`: The service's required configuration keys."""

ROOT = ''
""":py:class:`str`: The root URL for the API."""

@abstractmethod
def __init__(self, *_, **kwargs):
self.service_name = kwargs.get('name')

@property
def headers(self):
"""Get the headers for the service requests.
Returns:
:py:class:`dict`: The header mapping.
"""
return {}

def url_builder(self, endpoint, *, root=None, params=None, url_params=None):
"""Create a URL for the specified endpoint.
Arguments:
endpoint (:py:class:`str`): The API endpoint to access.
root: (:py:class:`str`, optional): The root URL for the
service API.
params: (:py:class:`dict`, optional): The values for format
into the created URL (defaults to ``None``).
url_params: (:py:class:`dict`, optional): Parameters to add
to the end of the URL (defaults to ``None``).
Returns:
:py:class:`str`: The resulting URL.
"""
if root is None:
root = self.ROOT
scheme, netloc, path, _, _ = urlsplit(root)
return urlunsplit((
scheme,
netloc,
urljoin(path, endpoint),
urlencode(url_params or {}),
'',
)).format(**params or {})


class TokenAuthMixin:
"""Mix-in class for implementing token authentication.
Arguments:
api_token (:py:class:`str`): A valid API token.
"""

TOKEN_ENV_VAR = None
""":py:class:`str`: The environment variable holding the token."""

def __init__(self, *, api_token, **kwargs):
self.api_token = api_token
super().__init__(**kwargs)

@classmethod
def from_env(cls):
"""Create a service instance from an environment variable."""
token = getenv(cls.TOKEN_ENV_VAR)
if token is None:
msg = 'missing environment variable: {!r}'.format(cls.TOKEN_ENV_VAR)
raise ValueError(msg)
return cls(api_token=token)


class UrlParamMixin(TokenAuthMixin):
"""Mix-in class for implementing URL parameter authentication."""

AUTH_PARAM = None
""":py:class:`str`: The name of the URL parameter."""

def url_builder(self, endpoint, params=None, url_params=None):
"""Add authentication URL parameter."""
if url_params is None:
url_params = OrderedDict()
url_params[self.AUTH_PARAM] = self.api_token
return super().url_builder(
endpoint,
params=params,
url_params=url_params,
)
50 changes: 12 additions & 38 deletions aslack/slack_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@

import aiohttp

from .utils import get_api_token, FriendlyError, raise_for_status
from .core import Service, UrlParamMixin
from .utils import FriendlyError, raise_for_status

logger = logging.getLogger(__name__)

Expand All @@ -32,20 +33,14 @@ class SlackApiError(FriendlyError):
"""Friendly messages for expected Slack API errors."""


class SlackApi:
class SlackApi(UrlParamMixin, Service):
"""Class to handle interaction with Slack's API.
Arguments:
token (:py:class:`str`): The user's API token.
Attributes:
API_BASE_URL (:py:class:`str`): The base URL for Slack API calls.
API_METHODS (:py:class:`dict`): The API methods defined by Slack.
"""

API_BASE_URL = 'https://slack.com/api'

API_METHODS = {
'api': {'test': 'Checks API calling code.'},
'auth': {'test': 'Checks authentication & identity.'},
Expand Down Expand Up @@ -166,17 +161,17 @@ class SlackApi:
},
}

def __init__(self, token=None):
if token is None:
token = get_api_token()
self.token = token
AUTH_PARAM = 'token'

REQUIRED = {'api_token'}

ROOT = 'https://slack.com/api/'

TOKEN_ENV_VAR = 'SLACK_API_TOKEN'

async def execute_method(self, method, **params):
"""Execute a specified Slack Web API method.
Note:
The API token is added automatically by this method.
Arguments:
method (:py:class:`str`): The name of the method.
**params (:py:class:`dict`): Any additional parameters
Expand All @@ -192,12 +187,9 @@ async def execute_method(self, method, **params):
contains an error message.
"""
url = self.create_url(method)
params = params.copy()
params['token'] = self.token
url = self.url_builder(method, url_params=params)
logger.info('Executing method %r', method)
logger.debug('...with params %r', params)
response = await aiohttp.get(url, params=params)
response = await aiohttp.get(url)
logger.info('Status: %r', response.status)
if response.status == 200:
json = await response.json()
Expand All @@ -208,24 +200,6 @@ async def execute_method(self, method, **params):
else:
raise_for_status(response)

@classmethod
def create_url(cls, method):
"""Create the full API URL for a given method.
Arguments:
method (:py:class:`str`): The name of the method.
Returns:
:py:class:`str`: The full API URL.
Raises:
SlackApiError: If the method is unknown.
"""
if not cls.method_exists(method):
raise SlackApiError('The {!r} method is unknown.'.format(method))
return '/'.join((cls.API_BASE_URL, method))

@classmethod
def method_exists(cls, method):
"""Whether a given method exists in the known API.
Expand Down
4 changes: 2 additions & 2 deletions aslack/slack_bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ async def from_api_token(cls, token=None, api_cls=SlackBotApi):
Arguments:
token (:py:class:`str`, optional): The bot's API token
(defaults to ``None``, which means looking in the
environment or taking user input).
environment).
api_cls (:py:class:`type`, optional): The class to create
as the ``api`` argument for API access (defaults to
:py:class:`aslack.slack_api.SlackBotApi`).
Expand All @@ -161,7 +161,7 @@ async def from_api_token(cls, token=None, api_cls=SlackBotApi):
:py:class:`SlackBot`: The new instance.
"""
api = api_cls(token)
api = api_cls.from_env() if token is None else api_cls(api_token=token)
data = await api.execute_method(cls.API_AUTH_ENDPOINT)
return cls(data['user_id'], data['user'], api)

Expand Down
33 changes: 1 addition & 32 deletions aslack/utils.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
"""Utility functionality.
Attributes:
API_TOKEN_ENV (:py:class:`str`): The environment variable to store
the user's API token in.
"""

import os
"""Utility functionality."""

from aiohttp import web_exceptions

API_TOKEN_ENV = 'SLACK_API_TOKEN'


class FriendlyError(Exception):
"""Exception with friendlier error messages.
Expand Down Expand Up @@ -57,27 +47,6 @@ def raise_for_status(response):
raise err(**payload)


def get_api_token():
"""Allow the user to enter their API token.
Note:
The token is added to the environment using the variable defined
in :py:const:`API_TOKEN_ENV`.
Returns:
:py:class:`str`: The user's API token.
"""
token = os.getenv(API_TOKEN_ENV)
if token:
return token
template = ('Enter your API token (this will be stored '
'as {} for future use): ').format(API_TOKEN_ENV)
token = input(template)
os.environ[API_TOKEN_ENV] = token
return token


def truncate(text, max_len=350, end='...'):
"""Truncate the supplied text for display.
Expand Down
7 changes: 7 additions & 0 deletions docs/aslack.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ aslack package
Submodules
**********

aslack.core module
------------------

.. automodule:: aslack.core
:members:
:show-inheritance:

aslack.slack_api module
-----------------------

Expand Down
36 changes: 12 additions & 24 deletions tests/test_slack_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,43 +28,31 @@ async def test_execute_method(aiohttp, status, result, error):
**{'json.return_value': json_future}
))
aiohttp.get.return_value = resp_future
api = SlackApi(DUMMY_TOKEN)
api = SlackApi(api_token=DUMMY_TOKEN)
method = 'auth.test'
if error is None:
assert await api.execute_method(method) == result
else:
with pytest.raises(error):
await api.execute_method(method)
aiohttp.get.assert_called_once_with(
'https://slack.com/api/{}'.format(method),
params={'token': DUMMY_TOKEN},
'https://slack.com/api/{}?token={}'.format(method, DUMMY_TOKEN)
)


@pytest.mark.parametrize('args,need_token', [
[(), True],
[(DUMMY_TOKEN,), False],
@pytest.mark.parametrize('kwargs,need_token', [
[{}, True],
[{'api_token': DUMMY_TOKEN}, False],
])
@mock.patch('aslack.slack_api.get_api_token')
def test_init(get_api_token, args, need_token):
get_api_token.return_value = DUMMY_TOKEN
api = SlackApi(*args)
assert api.token == DUMMY_TOKEN
@mock.patch('aslack.core.getenv')
def test_init(getenv, kwargs, need_token):
getenv.return_value = DUMMY_TOKEN
if need_token:
get_api_token.assert_called_once_with()


@pytest.mark.parametrize('method,exists', [
('auth.test', True),
('foo.bar', False)
])
def test_create_url(method, exists):
if exists:
expected = 'https://slack.com/api/{}'.format(method)
assert SlackApi.create_url(method) == expected
api = SlackApi.from_env()
getenv.assert_called_once_with(SlackApi.TOKEN_ENV_VAR)
else:
with pytest.raises(SlackApiError):
SlackApi.create_url(method)
api = SlackApi(**kwargs)
assert api.api_token == DUMMY_TOKEN


def test_api_subclass_factory():
Expand Down
23 changes: 0 additions & 23 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,12 @@
import pytest

from aslack.utils import (
API_TOKEN_ENV,
get_api_token,
FriendlyError,
raise_for_status,
truncate,
)


@mock.patch('aslack.utils.os')
@mock.patch('aslack.utils.input')
def test_get_api_token_from_input(input, os):
os.environ = {}
os.getenv.return_value = None
test_token = 'token'
input.return_value = test_token
assert get_api_token() == test_token
os.getenv.assert_called_once_with(API_TOKEN_ENV)
assert input.call_count == 1
assert os.environ == {API_TOKEN_ENV: test_token}


@mock.patch('aslack.utils.os')
def test_get_api_token_from_environment(os):
test_token = 'token'
os.getenv.return_value = test_token
assert get_api_token() == test_token
os.getenv.assert_called_once_with(API_TOKEN_ENV)


@pytest.mark.parametrize('input_,expected', [
(('foo',), ('bar',)),
(('baz',), ('baz',)),
Expand Down

0 comments on commit ead5f9b

Please sign in to comment.