Skip to content

Commit 249b0b6

Browse files
hirosassahirohito-sasakawaHi-king
authored
add Slack error handling and remove use of deprecated API method (#141)
* fix: deprecated api methods and error handling * fix test * add README * minor fix * Update gokart/slack/README.md apply review suggestion Co-authored-by: K.O. <hikingko1@gmail.com> * add slack_notification document on doc directory Co-authored-by: hirohito-sasakawa <hirohito-sasakawa@m3.com> Co-authored-by: K.O. <hikingko1@gmail.com>
1 parent 558a03f commit 249b0b6

5 files changed

Lines changed: 138 additions & 53 deletions

File tree

docs/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Table of Contents
2020
task_on_kart
2121
task_information
2222
task_settings
23-
23+
slack_notification
2424

2525
API References
2626
--------------

docs/slack_notification.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
gokart Slack notification
2+
=========================
3+
4+
Prerequisites
5+
-------------
6+
7+
Prepare following environmental variables:
8+
9+
.. code:: sh
10+
11+
export SLACK_TOKEN=xoxb-your-token // should use token starts with "xoxb-" (bot token is preferable)
12+
export SLACK_CHANNEL=channel-name // not "#channel-name", just "channel-name"
13+
14+
15+
A Slack bot token can obtain from `here <https://api.slack.com/apps>`_.
16+
17+
A bot token needs following scopes:
18+
19+
- `channels:read`
20+
- `chat:write`
21+
- `files:write`
22+
23+
More about scopes are `here <https://api.slack.com/scopes>`_.
24+
25+
Implement Slack notification
26+
----------------------------
27+
28+
Write following codes pass arguments to your gokart workflow.
29+
30+
.. code:: python
31+
cmdline_args = sys.argv[1:]
32+
if 'SLACK_CHANNEL' in os.environ:
33+
cmdline_args.append(f'--SlackConfig-channel={os.environ["SLACK_CHANNEL"]}')
34+
if 'SLACK_TO_USER' in os.environ:
35+
cmdline_args.append(f'--SlackConfig-to-user={os.environ["SLACK_TO_USER"]}')
36+
gokart.run(cmdline_args)
37+

gokart/slack/slack_api.py

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from logging import getLogger
2+
import time
23

34
import slack
4-
5+
from slack.errors import SlackApiError
56

67
logger = getLogger(__name__)
78

@@ -24,33 +25,27 @@ def __init__(self, token, channel: str, to_user: str) -> None:
2425
self._channel_id = self._get_channel_id(channel)
2526
self._to_user = to_user if to_user == '' or to_user.startswith('@') else '@' + to_user
2627

27-
def _get_channels(self, channels=[], cursor=None):
28-
params = {}
29-
if cursor:
30-
params['cursor'] = cursor
31-
response = self._client.api_call('channels.list', http_verb="GET", params=params)
32-
if not response['ok']:
33-
raise ChannelListNotLoadedError(f'Error while loading channels. The error reason is "{response["error"]}".')
34-
channels += response.get('channels', [])
35-
if not channels:
36-
raise ChannelListNotLoadedError('Channel list is empty.')
37-
if response['response_metadata']['next_cursor']:
38-
return self._get_channels(channels, response['response_metadata']['next_cursor'])
39-
else:
40-
return channels
41-
4228
def _get_channel_id(self, channel_name):
43-
for channel in self._get_channels():
44-
if channel['name'] == channel_name:
45-
return channel['id']
46-
raise ChannelNotFoundError(f'Channel {channel_name} is not found in public channels.')
29+
params = {'exclude_archived': True, 'limit': 100}
30+
try:
31+
for channels in self._client.conversations_list(params=params):
32+
if not channels:
33+
raise ChannelListNotLoadedError('Channel list is empty.')
34+
for channel in channels.get('channels', []):
35+
if channel['name'] == channel_name:
36+
return channel['id']
37+
raise ChannelNotFoundError(f'Channel {channel_name} is not found in public channels.')
38+
except (ChannelNotFoundError, SlackApiError) as e:
39+
logger.warning(f'The job will start without slack notification: {e}')
4740

4841
def send_snippet(self, comment, title, content):
49-
request_body = dict(
50-
channels=self._channel_id,
51-
initial_comment=f'<{self._to_user}> {comment}' if self._to_user else comment,
52-
content=content,
53-
title=title)
54-
response = self._client.api_call('files.upload', data=request_body)
55-
if not response['ok']:
56-
raise FileNotUploadedError(f'Error while uploading file. The error reason is "{response["error"]}".')
42+
try:
43+
request_body = dict(channels=self._channel_id,
44+
initial_comment=f'<{self._to_user}> {comment}' if self._to_user else comment,
45+
content=content,
46+
title=title)
47+
response = self._client.api_call('files.upload', data=request_body)
48+
if not response['ok']:
49+
raise FileNotUploadedError(f'Error while uploading file. The error reason is "{response["error"]}".')
50+
except (FileNotUploadedError, SlackApiError) as e:
51+
logger.warning(f'Failed to send slack notification: {e}')

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
license='MIT License',
3232
packages=find_packages(),
3333
install_requires=install_requires,
34-
tests_require=['moto==1.3.6'],
34+
tests_require=['moto==1.3.6', 'testfixtures==6.14.2'],
3535
test_suite='test',
3636
classifiers=['Programming Language :: Python :: 3.6'],
3737
)

test/slack/test_slack_api.py

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,65 +3,118 @@
33
from unittest import mock
44
from unittest.mock import MagicMock
55

6+
from testfixtures import LogCapture
7+
from slack.web.slack_response import SlackResponse
8+
from slack import WebClient
9+
610
import gokart.slack
711

812
logger = getLogger(__name__)
913

1014

15+
def _slack_response(token, data):
16+
return SlackResponse(client=WebClient(token=token),
17+
http_verb="POST",
18+
api_url="http://localhost:3000/api.test",
19+
req_args={},
20+
data=data,
21+
headers={},
22+
status_code=200)
23+
24+
1125
class TestSlackAPI(unittest.TestCase):
1226
@mock.patch('gokart.slack.slack_api.slack.WebClient')
1327
def test_initialization_with_invalid_token(self, patch):
14-
def _channels_list(method, http_verb="POST", params={}):
15-
assert method == 'channels.list'
16-
return {'ok': False, 'error': 'error_reason'}
28+
def _conversations_list(params={}):
29+
return _slack_response(token='invalid', data={'ok': False, 'error': 'error_reason'})
1730

1831
mock_client = MagicMock()
19-
mock_client.api_call = MagicMock(side_effect=_channels_list)
32+
mock_client.conversations_list = MagicMock(side_effect=_conversations_list)
2033
patch.return_value = mock_client
2134

22-
with self.assertRaises(gokart.slack.slack_api.ChannelListNotLoadedError):
35+
with LogCapture() as l:
2336
gokart.slack.SlackAPI(token='invalid', channel='test', to_user='test user')
37+
l.check(('gokart.slack.slack_api', 'WARNING',
38+
'The job will start without slack notification: Channel test is not found in public channels.'))
2439

2540
@mock.patch('gokart.slack.slack_api.slack.WebClient')
2641
def test_invalid_channel(self, patch):
27-
def _channels_list(method, http_verb="POST", params={}):
28-
assert method == 'channels.list'
29-
return {'ok': True, 'channels': [{'name': 'valid', 'id': 'valid_id'}], 'response_metadata': {'next_cursor': ''}}
42+
def _conversations_list(params={}):
43+
return _slack_response(token='valid',
44+
data={
45+
'ok': True,
46+
'channels': [{
47+
'name': 'valid',
48+
'id': 'valid_id'
49+
}],
50+
'response_metadata': {
51+
'next_cursor': ''
52+
}
53+
})
3054

3155
mock_client = MagicMock()
32-
mock_client.api_call = MagicMock(side_effect=_channels_list)
56+
mock_client.conversations_list = MagicMock(side_effect=_conversations_list)
3357
patch.return_value = mock_client
3458

35-
with self.assertRaises(gokart.slack.slack_api.ChannelNotFoundError):
59+
with LogCapture() as l:
3660
gokart.slack.SlackAPI(token='valid', channel='invalid_channel', to_user='test user')
61+
l.check((
62+
'gokart.slack.slack_api', 'WARNING',
63+
'The job will start without slack notification: Channel invalid_channel is not found in public channels.'
64+
))
3765

3866
@mock.patch('gokart.slack.slack_api.slack.WebClient')
3967
def test_send_snippet_with_invalid_token(self, patch):
40-
def _api_call(*args, **kwargs):
41-
if args[0] == 'channels.list':
42-
return {'ok': True, 'channels': [{'name': 'valid', 'id': 'valid_id'}], 'response_metadata': {'next_cursor': ''}}
43-
if args[0] == 'files.upload':
44-
return {'ok': False, 'error': 'error_reason'}
45-
assert False
68+
def _conversations_list(params={}):
69+
return _slack_response(token='valid',
70+
data={
71+
'ok': True,
72+
'channels': [{
73+
'name': 'valid',
74+
'id': 'valid_id'
75+
}],
76+
'response_metadata': {
77+
'next_cursor': ''
78+
}
79+
})
80+
81+
def _api_call(method, data={}):
82+
assert method == 'files.upload'
83+
return {'ok': False, 'error': 'error_reason'}
4684

4785
mock_client = MagicMock()
86+
mock_client.conversations_list = MagicMock(side_effect=_conversations_list)
4887
mock_client.api_call = MagicMock(side_effect=_api_call)
4988
patch.return_value = mock_client
5089

51-
with self.assertRaises(gokart.slack.slack_api.FileNotUploadedError):
90+
with LogCapture() as l:
5291
api = gokart.slack.SlackAPI(token='valid', channel='valid', to_user='test user')
5392
api.send_snippet(comment='test', title='title', content='content')
93+
l.check(
94+
('gokart.slack.slack_api', 'WARNING',
95+
'Failed to send slack notification: Error while uploading file. The error reason is "error_reason".'))
5496

5597
@mock.patch('gokart.slack.slack_api.slack.WebClient')
5698
def test_send(self, patch):
57-
def _api_call(*args, **kwargs):
58-
if args[0] == 'channels.list':
59-
return {'ok': True, 'channels': [{'name': 'valid', 'id': 'valid_id'}], 'response_metadata': {'next_cursor': ''}}
60-
if args[0] == 'files.upload':
61-
return {'ok': True}
62-
assert False
99+
def _conversations_list(params={}):
100+
return _slack_response(token='valid',
101+
data={
102+
'ok': True,
103+
'channels': [{
104+
'name': 'valid',
105+
'id': 'valid_id'
106+
}],
107+
'response_metadata': {
108+
'next_cursor': ''
109+
}
110+
})
111+
112+
def _api_call(method, data={}):
113+
assert method == 'files.upload'
114+
return {'ok': False, 'error': 'error_reason'}
63115

64116
mock_client = MagicMock()
117+
mock_client.conversations_list = MagicMock(side_effect=_conversations_list)
65118
mock_client.api_call = MagicMock(side_effect=_api_call)
66119
patch.return_value = mock_client
67120

0 commit comments

Comments
 (0)