├── VERSION ├── requirements.txt ├── src ├── tests │ ├── responses │ │ ├── file.txt │ │ ├── check.json │ │ ├── channels.json │ │ ├── in.json │ │ ├── users.json │ │ └── messages.json │ ├── test_output.py │ ├── test_in.py │ ├── test_check.py │ ├── test_functions.py │ ├── test_payload.py │ └── test_base.py ├── bin │ ├── in │ ├── out │ └── check └── lib │ ├── functions.py │ ├── in_op.py │ ├── payload.py │ ├── base.py │ ├── check_op.py │ └── out_op.py ├── .travis.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── examples.md ├── README.md └── .pylintrc /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.10 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | slackclient 2 | jinja2 3 | -------------------------------------------------------------------------------- /src/tests/responses/file.txt: -------------------------------------------------------------------------------- 1 | content1 2 | content2 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | # - "3.6" 5 | 6 | install: 7 | - "pip install -r requirements.txt" 8 | - "pip install nose coverage pylint" 9 | 10 | script: 11 | - make lint 12 | - make detailed-tests 13 | - make coverage 14 | -------------------------------------------------------------------------------- /src/tests/responses/check.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "slack_token": "xoxb-sdasd-1231231231", 4 | "channel": "test-bender-dev", 5 | "bot_name": "bender", 6 | "grammar": "^(superApp)\\s+(deploy)\\s+(live|staging)\\s+(\\S+)($|\\s+)" 7 | } 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *_test.json 3 | 4 | # pyenv 5 | .python-version 6 | 7 | # Unit test / coverage reports 8 | htmlcov/ 9 | .tox/ 10 | .coverage 11 | .coverage.* 12 | .cache 13 | nosetests.xml 14 | coverage.xml 15 | *.cover 16 | .hypothesis/ 17 | 18 | # Installer logs 19 | pip-log.txt 20 | pip-delete-this-directory.txt 21 | 22 | # Project specific 23 | .vscode/* 24 | example/* -------------------------------------------------------------------------------- /src/tests/responses/channels.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "channels": [ 4 | { 5 | "id": "C024BE91L", 6 | "name": "testChannel", 7 | "created": 1360782804, 8 | "creator": "U1G51PPC", 9 | "is_archived": false, 10 | "is_member": false, 11 | "num_members": 6 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /src/bin/in: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | exec 3>&1 # make stdout available as fd 3 for the result 5 | exec 1>&2 # redirect all output to stderr for logging 6 | 7 | TMPDIR="/tmp" 8 | MY_PATH="$(dirname "$0")" # relative 9 | DIR="$( cd "$MY_PATH" && pwd )" # absolutized and normalized 10 | 11 | payload=$(mktemp $TMPDIR/bender-resource.XXXXXX) 12 | cat > "$payload" <&0 13 | 14 | "${DIR}/$(basename "$0")_op.py" "$1" >&3 < "$payload" 15 | -------------------------------------------------------------------------------- /src/bin/out: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | exec 3>&1 # make stdout available as fd 3 for the result 5 | exec 1>&2 # redirect all output to stderr for logging 6 | 7 | TMPDIR="/tmp" 8 | MY_PATH="$(dirname "$0")" # relative 9 | DIR="$( cd "$MY_PATH" && pwd )" # absolutized and normalized 10 | 11 | payload=$(mktemp $TMPDIR/bender-resource.XXXXXX) 12 | cat > "$payload" <&0 13 | 14 | "${DIR}/$(basename "$0")_op.py" "$1" >&3 < "$payload" 15 | -------------------------------------------------------------------------------- /src/bin/check: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | exec 3>&1 # make stdout available as fd 3 for the result 5 | exec 1>&2 # redirect all output to stderr for logging 6 | 7 | TMPDIR="/tmp" 8 | MY_PATH="$(dirname "$0")" # relative 9 | DIR="$( cd "$MY_PATH" && pwd )" # absolutized and normalized 10 | 11 | payload=$(mktemp $TMPDIR/bender-resource.XXXXXX) 12 | cat > "$payload" <&0 13 | 14 | "${DIR}/$(basename "$0")_op.py" ${1} >&3 < "$payload" 15 | -------------------------------------------------------------------------------- /src/tests/responses/in.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "slack_token": "xoxb-213213-sadsadsa2312", 4 | "channel": "test-bender-dev", 5 | "bot_name": "bender", 6 | "grammar": "^(superApp)\\s+(deploy)\\s+(live|staging)\\s+(\\S+)($|\\s+)" 7 | }, 8 | "version": { 9 | "id_ts": 1358546512.010007, 10 | "uid": "U0xV31PHC", 11 | "username": "ahelal", 12 | "message": "<@U01B12FDS> superApp deploy staging 1.2-rc.1" 13 | } 14 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.6 2 | 3 | RUN apk add --update \ 4 | python \ 5 | python-dev \ 6 | py-pip 7 | 8 | ADD src/bin /opt/resource/ 9 | ADD src/lib /opt/resource/ 10 | ADD requirements.txt /tmp 11 | 12 | RUN chmod +x /opt/resource/* \ 13 | && pip install -r /tmp/requirements.txt \ 14 | && rm /tmp/requirements.txt 15 | 16 | # Do some clean up 17 | RUN echo "# Cleaning up" && echo "" \ 18 | && rm -rf /tmp/* \ 19 | && rm -rf /var/cache/apk/* \ 20 | && rm -rf /root/.cache/ 21 | -------------------------------------------------------------------------------- /src/tests/responses/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "members": [ 4 | { 5 | "first_name": "Adham", 6 | "last_name": "Helal", 7 | "name": "ahelal", 8 | "id": "U0xV31PHC" 9 | }, 10 | { 11 | "first_name": "Ali", 12 | "last_name": "Rizwan", 13 | "name": "alir", 14 | "id": "U1G51PPC" 15 | }, 16 | { 17 | "first_name": "Bender", 18 | "last_name": "Rodriguez", 19 | "name": "theBender", 20 | "id": "U01B12FDS" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Adham 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += --warn-undefined-variables 2 | .DEFAULT_GOAL := build 3 | PHONY: all build test push push-latest clean 4 | 5 | NAME := quay.io/ahelal/bender 6 | VERSION := $(shell cat VERSION) 7 | 8 | M = $(shell printf "\033[34;1m▶\033[0m") 9 | 10 | all: test squash build 11 | 12 | build: 13 | $(info $(M) Building ${NAME}:${VERSION} and ${NAME}:dev …) 14 | @docker build -t ${NAME}:${VERSION} -t ${NAME}:dev -f Dockerfile . 15 | 16 | squash: 17 | # requires docker-squash https://github.com/goldmann/docker-squash 18 | $(info $(M) Squashing ${NAME}:${VERSION} …) 19 | @docker-squash -t ${NAME}:${VERSION} ${NAME}:${VERSION} 20 | 21 | tests: 22 | $(info $(M) Running tests for ) 23 | @PYTHONPATH="src/lib" nosetests -w "src/tests" 24 | 25 | detailed-tests: 26 | $(info $(M) Running tests for $(VERSION)) 27 | @PYTHONPATH="src/lib" nosetests --detailed-errors -w "src/tests" -vv --nocapture 28 | 29 | coverage: 30 | @coverage report -m src/lib/*.py 31 | 32 | lint: 33 | @pylint src/lib/*.py 34 | 35 | push: 36 | $(info $(M) Pushing $(NAME):$(VERSION) ) 37 | @docker tag $(NAME):dev $(NAME):$(VERSION) 38 | @docker push "${NAME}:${VERSION}" 39 | 40 | push-latest: 41 | $(info $(M) Linking latest to $(NAME):$(VERSION) and pushing tag latest ) 42 | docker tag $(NAME):$(VERSION) $(NAME):latest 43 | docker push "${NAME}:latest" 44 | 45 | # clean: 46 | # $(info $(M) Cleaning) 47 | -------------------------------------------------------------------------------- /src/tests/responses/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "latest": "1358547726.000003", 4 | "messages": [ 5 | { 6 | "type": "message", 7 | "ts": "1358546510.000008", 8 | "user": "U1G51PPC", 9 | "text": "Hello whats Up" 10 | }, 11 | { 12 | "type": "something_else", 13 | "ts": "1358546511.000207", 14 | "wibblr": true 15 | }, 16 | { 17 | "type": "message", 18 | "ts": "1358546512.010007", 19 | "user": "U0xV31PHC", 20 | "text": "<@U01B12FDS> superApp deploy staging 1.2-rc.1", 21 | "is_starred": true 22 | }, 23 | { 24 | "type": "something_else", 25 | "ts": "1358546513.000007", 26 | "wibblr": true 27 | }, 28 | { 29 | "type": "message", 30 | "ts": "1358546514.000008", 31 | "user": "U1G51PPC", 32 | "text": "Hey man. I am deploying live" 33 | }, 34 | { 35 | "type": "message", 36 | "ts": "1358546515.010007", 37 | "user": "U1G51PPC", 38 | "text": "<@U01B12FDS> superApp deploy live 1.1", 39 | "is_starred": true 40 | }, 41 | { 42 | "type": "message", 43 | "ts": "1358546516.000008", 44 | "user": "U1G51PPC", 45 | "text": "Cool :+1" 46 | } 47 | ], 48 | "has_more": false 49 | } -------------------------------------------------------------------------------- /src/tests/test_output.py: -------------------------------------------------------------------------------- 1 | import json 2 | import mock 3 | import os 4 | import sys 5 | from unittest import TestCase 6 | 7 | import out_op 8 | 9 | 10 | class OutTest(TestCase): 11 | 12 | @mock.patch('out_op.read_content_from_file') 13 | @mock.patch('out_op.Out._filter') 14 | @mock.patch('out_op.Out._get_channel_group_info') 15 | @mock.patch('out_op.Out._call_api') 16 | def setUp(self, mock_call_api, mock_get_channel_group_info, mock_filter, mock_read_content_from_file): 17 | with open('{}/responses/users.json'.format(os.path.dirname(__file__))) as f: 18 | mock_call_api.return_value = json.load(f) 19 | mock_get_channel_group_info.return_value = "C024BE91L", "channels" 20 | mock_filter.return_value = "U01B12FDS" 21 | mock_read_content_from_file.return_value = '{"version": 1, "metadata": [1], "original_msg": "HI"}' 22 | 23 | self.grammar = "^(superApp)\\s+(deploy)\\s+(live|staging)\\s+(\\S+)($|\\s+)" 24 | self.resource = out_op.Out(token="token", 25 | channel="testChannel", 26 | bot="theBender", 27 | working_dir="/tmp", 28 | grammar=self.grammar, 29 | path="bender_path", 30 | reply="testing 1.2.3", 31 | reply_thread="reply_thread") 32 | 33 | def test__init__(self): 34 | # Channel info 35 | self.assertEqual(self.resource.path, "bender_path") 36 | self.assertEqual(self.resource.reply, "testing 1.2.3") 37 | self.assertEqual(self.resource.reply_thread, "reply_thread") 38 | self.assertEqual(self.resource.working_dir, "/tmp") 39 | 40 | self.assertEqual(self.resource.version, 1) 41 | self.assertEqual(self.resource.metadata, [1]) 42 | self.assertEqual(self.resource.original_msg, "HI") 43 | 44 | @mock.patch('out_op.json.dumps') 45 | @mock.patch('out_op.print', create=True) 46 | def test_out_output(self, mock_print, mock_json_dumps): 47 | self.resource.version = {"ts": "123"} 48 | self.resource.metadata = [{"a": 1, "b": 2}] 49 | # Simply return x 50 | mock_json_dumps.side_effect = lambda x, indent=0, sort_keys=0: x 51 | self.resource.out_output() 52 | mock_json_dumps.assert_called() 53 | mock_print.assert_called_with({'version': {'ts': '123'}, 'metadata': [{'a': 1, 'b': 2}]}) 54 | -------------------------------------------------------------------------------- /src/lib/functions.py: -------------------------------------------------------------------------------- 1 | ''' Helper functions''' 2 | from __future__ import print_function 3 | 4 | import os 5 | import sys 6 | 7 | from jinja2 import (StrictUndefined, Template, TemplateSyntaxError, UndefinedError) 8 | 9 | 10 | def fail_unless(condition, msg): 11 | """If condition is not True print msg and exit with status code 1""" 12 | if not condition: 13 | print("{}".format(msg), file=sys.stderr) 14 | exit(1) 15 | 16 | def template_str(text, variables): 17 | ''' Return a templated text ''' 18 | 19 | # Merge ENV with variables passed 20 | variables.update({"ENV": os.environ}) 21 | try: 22 | return Template(text, undefined=StrictUndefined).render(variables) 23 | except TemplateSyntaxError as syntax_error: 24 | fail_unless(False, "Template syntax error. Template string: '{}'\n.{}".format(text, syntax_error)) 25 | except UndefinedError as undefined_error: 26 | fail_unless(False, "Undefined variable. Template string: '{}'\n.{}".format(text, undefined_error)) 27 | except TypeError as type_error: 28 | fail_unless(False, "Type error. Template string: '{}'\n.{}".format(text, type_error)) 29 | 30 | def template_with_regex(text, regex, **template_vars): 31 | ''' Add regex groupdict or groups to our environment''' 32 | if regex and regex.groupdict(): 33 | template_vars.update({"regex": regex.groupdict()}) 34 | elif regex and regex.groups(): 35 | template_vars.update({"regex": regex.groups()}) 36 | return template_str(text, template_vars) 37 | 38 | def write_to_file(content, output_file): 39 | ''' write content to output_file''' 40 | output_file_fd = open(output_file, 'w') 41 | print(content, file=output_file_fd) 42 | 43 | def read_if_exists(base_path, content): 44 | ''' Return content of file, if file exists else return content.''' 45 | path = os.path.abspath(os.path.join(base_path, str(content))) 46 | is_file = os.path.isfile(path) 47 | if is_file: 48 | return read_content_from_file(path) 49 | return content 50 | 51 | def read_content_from_file(path): 52 | ''' Return content of file.''' 53 | try: 54 | with open(path) as file_desc: 55 | return file_desc.read() 56 | except IOError as file_error: 57 | fail_unless(False, "Failed to read file '{}'. IOError: '{}".format(path, file_error)) 58 | 59 | def list_get(a_list, idx, default=None): 60 | ''' Return index of a list if exists''' 61 | try: 62 | return a_list[idx] 63 | except IndexError: 64 | return default 65 | -------------------------------------------------------------------------------- /src/tests/test_in.py: -------------------------------------------------------------------------------- 1 | ''' testing in operation''' 2 | import json 3 | import os 4 | from unittest import TestCase 5 | 6 | import mock 7 | 8 | import in_op 9 | 10 | 11 | class InTest(TestCase): 12 | 13 | @mock.patch('in_op.In._filter') 14 | @mock.patch('in_op.In._get_channel_group_info') 15 | @mock.patch('in_op.In._call_api') 16 | def setUp(self, mock_call_api, mock_get_channel_group_info, mock_filter): 17 | 18 | self.script_dir = os.path.dirname(__file__) 19 | with open('{}/responses/users.json'.format(self.script_dir)) as f: 20 | mock_call_api.return_value = json.load(f) 21 | mock_get_channel_group_info.return_value = "C024BE91L", "channels" 22 | mock_filter.return_value = "U01B12FDS" 23 | 24 | self.grammar = "^(superApp)\s+(deploy)\s+(live|staging)\s+(\S+)($|\s+)" 25 | self.resource = in_op.In(template="VERSION={{ regex[4] }}", template_filename="template_filename", 26 | grammar=self.grammar, path="bender_path", reply="testing 1.2.3") 27 | 28 | def test__init__(self): 29 | 30 | self.assertEqual(self.resource.template_filename, "template_filename") 31 | 32 | # TODO test_in_logic 33 | 34 | @mock.patch('in_op.write_to_file') 35 | @mock.patch('in_op.json.dumps') 36 | @mock.patch('in_op.print', create=True) 37 | def test_in_output(self, mock_print, mock_json_dumps, mock_write_to_file): 38 | # Test with no templated_string 39 | self.resource.templated_string = False 40 | self.resource.working_dir = "/tmp" 41 | self.resource.original_msg = "OR" 42 | self.resource.version = {"ts": "123"} 43 | self.resource.metadata = [{"a": 1, "b": 2}] 44 | # Expected output 45 | output = {'version': {'ts': '123'}, 'metadata': [{'a': 1, 'b': 2}], 'original_msg': 'OR'} 46 | mock_json_dumps.side_effect = lambda x, indent=0, sort_keys=0: x 47 | # Call in_output 48 | self.resource.in_output() 49 | # Assert 50 | mock_json_dumps.assert_called() 51 | mock_print.assert_called_with(output) 52 | mock_write_to_file.assert_called_once_with(output, "/tmp/bender.json") 53 | 54 | # Test with templated_String 55 | self.resource.template_filename = "template.txt" 56 | mock_write_to_file.reset_mock() 57 | self.resource.templated_string = "TEMPLATED_STRING" 58 | # Call in_output 59 | self.resource.in_output() 60 | mock_write_to_file.assert_any_call(output, "/tmp/bender.json") 61 | mock_write_to_file.assert_any_call("TEMPLATED_STRING", "/tmp/template.txt") 62 | self.assertEquals(2, mock_write_to_file.call_count) 63 | -------------------------------------------------------------------------------- /src/lib/in_op.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' The IN operation for bender resource''' 3 | from __future__ import print_function 4 | 5 | import json 6 | import os 7 | 8 | from payload import PayLoad 9 | from base import Base 10 | from functions import write_to_file, list_get, template_with_regex, fail_unless 11 | 12 | class In(Base): 13 | ''' In resource class''' 14 | 15 | def __init__(self, **kwargs): 16 | Base.__init__(self, **kwargs) 17 | self.metadata = [] 18 | self.template = kwargs.get("template") 19 | self.template_filename = os.path.basename(kwargs.get("template_filename")) 20 | self.templated_string = None 21 | self.original_msg = "" 22 | 23 | def _get_single_msg(self, timestamp): 24 | message = self._call_api(self.channel_type + ".history", 25 | channel=self.channel_id, 26 | inclusive=True, 27 | count=1, 28 | latest=timestamp) 29 | fail_unless(message["messages"], "Message '{}' is empty. Might have been removed".format(timestamp)) 30 | return message 31 | 32 | def in_logic(self): 33 | """Concourse resource `in` logic """ 34 | message = list_get(self._get_single_msg(self.version["id_ts"])["messages"], 0, {}) 35 | self.original_msg = message.get("text") 36 | user = self._filter(self.users['members'], "name", "id", message.get("user")) 37 | if self.template: 38 | regex = self._msg_grammar(self.original_msg) 39 | self.templated_string = template_with_regex(self.template, regex, user=user) 40 | if self.mention: 41 | meta_data_msg = self._remove_bot_id(self.original_msg, self.bot_id) 42 | else: 43 | meta_data_msg = self.original_msg 44 | 45 | self.metadata = [{"name": "User", "value": user}, 46 | {"name": "Message", "value": meta_data_msg}] 47 | 48 | def in_output(self): 49 | """Concourse resource `in` main """ 50 | 51 | output = {"version": self.version, "metadata": self.metadata, 52 | "original_msg": self.original_msg} 53 | # Write response as bender.json for further use 54 | write_to_file(json.dumps(output), '{}/bender.json'.format(self.working_dir)) 55 | # Write template if specified 56 | if self.templated_string: 57 | write_to_file(self.templated_string, '{}/{}'.format(self.working_dir, self.template_filename)) 58 | 59 | # Print concourse output string 60 | print(json.dumps(output, indent=4, sort_keys=True)) 61 | 62 | def main(): 63 | ''' Main''' 64 | payload = PayLoad() 65 | fail_unless(payload.args["version"], "version is required") 66 | 67 | slack_client = In(**payload.args) 68 | slack_client.in_logic() 69 | slack_client.in_output() 70 | 71 | if __name__ == '__main__': 72 | main() 73 | -------------------------------------------------------------------------------- /src/lib/payload.py: -------------------------------------------------------------------------------- 1 | ''' Parse payload options and command line argument''' 2 | from __future__ import print_function 3 | 4 | import json 5 | import sys 6 | import os 7 | 8 | from functions import fail_unless 9 | 10 | class PayLoad(object): # pylint: disable=too-few-public-methods 11 | ''' Payload class ''' 12 | 13 | def __init__(self): 14 | self.payload = self._get_payload() 15 | self.args = {} 16 | try: 17 | self.source = self.payload["source"] 18 | except KeyError: 19 | fail_unless(False, "Source not configured.") 20 | else: 21 | self.params = self.payload.get("params", {}) 22 | self.parse_payload() 23 | # argument pass with dir 24 | self.args["working_dir"] = self._get_dir_from_argv() 25 | 26 | @staticmethod 27 | def _get_payload(): 28 | ''' Return a dict after serializing the JSON payload from STDIN ''' 29 | try: 30 | payload = json.load(sys.stdin) 31 | except ValueError as value_error: 32 | fail_unless(False, "JSON Input error: {}".format(value_error)) 33 | return payload 34 | 35 | @staticmethod 36 | def _get_dir_from_argv(): 37 | if len(sys.argv) < 2: 38 | return False 39 | 40 | fail_unless(os.path.isdir(sys.argv[1]), "Invalid dir argument passed '{}'".format(sys.argv[1])) 41 | return sys.argv[1] 42 | 43 | def parse_payload(self): 44 | ''' Parse payload passed by concourse''' 45 | self.args["version"] = self.payload.get("version") 46 | if self.args["version"] is None: 47 | self.args["version"] = {} 48 | try: 49 | # Mandatory source configs 50 | self.args["slack_token"] = self.source["slack_token"] 51 | self.args["channel"] = self.source["channel"] 52 | except KeyError as value_error: 53 | fail_unless(False, "Source config '{}' required".format(value_error)) 54 | # Optional source configs 55 | self.args["bot_name"] = self.source.get("bot_name", "bender") 56 | self.args["mention"] = self.source.get("mention", True) 57 | self.args["as_user"] = self.source.get("as_user", True) 58 | self.args["bot_icon_emoji"] = self.source.get("bot_icon_emoji") 59 | self.args["bot_icon_url"] = self.source.get("bot_icon_url") 60 | self.args["grammar"] = self.source.get("grammar") 61 | self.args["template"] = self.source.get("template") 62 | self.args["template_filename"] = self.source.get("template_filename", "template_file.txt") 63 | self.args["slack_unread"] = self.source.get("slack_unread") 64 | # Optional params config 65 | self.args["path"] = self.params.get("path") 66 | self.args["reply"] = self.params.get("reply") 67 | self.args["reply_attachments"] = self.params.get("reply_attachments") 68 | self.args["reply_thread"] = self.params.get("reply_thread", True) 69 | -------------------------------------------------------------------------------- /src/lib/base.py: -------------------------------------------------------------------------------- 1 | ''' Base class and some helper functions''' 2 | from __future__ import print_function 3 | 4 | import re 5 | 6 | from slackclient import SlackClient 7 | from functions import fail_unless 8 | 9 | class Base(object): # pylint: disable=too-few-public-methods,too-many-instance-attributes 10 | """Slack Concourse resource implementation""" 11 | 12 | def __init__(self, **kwargs): 13 | self.slack_client = SlackClient(kwargs.get("slack_token")) 14 | self.bot = kwargs.get("bot_name") 15 | self.channel = kwargs.get("channel") 16 | self.grammar = kwargs.get("grammar") 17 | self.version = kwargs.get("version") 18 | self.working_dir = kwargs.get("working_dir") 19 | self.slack_unread = kwargs.get("slack_unread") 20 | self.users = self._call_api("users.list", presence=0) 21 | self.mention = kwargs.get("mention") 22 | if self.mention: 23 | self.bot_id = self._filter(self.users['members'], "id", "name", self.bot) 24 | fail_unless(self.bot_id, "Unable to find bot name '{}'".format(self.bot)) 25 | if not self.grammar and not self.mention: 26 | fail_unless(False, "At least one parameter is required 'grammar', 'mention'.") 27 | 28 | self.channel_id, self.channel_type = self._get_channel_group_info() 29 | fail_unless(self.channel_id, "Unable to find channel/group '{}'".format(self.channel)) 30 | 31 | def _call_api(self, method, **kwargs): 32 | """Interface to Slack API""" 33 | api_response = self.slack_client.api_call(method, **kwargs) 34 | response_status = api_response.get("ok", False) 35 | fail_unless(response_status, "Slack API Call failed. method={} response={}".format(method, api_response)) 36 | return api_response 37 | 38 | @staticmethod 39 | def _remove_bot_id(msg, bot_id): 40 | '''Return a text after removing <@BOT_NAME> from message.''' 41 | regex = re.compile(r"^(\s+)?<@{}>(.*)".format(bot_id)) 42 | regex = regex.match(msg) 43 | if not regex: 44 | return None 45 | return regex.groups()[1].strip() 46 | 47 | def _msg_grammar(self, msg): 48 | ''' Return only message if grammar is not defined. Return regex if grammar is defined. And None if no match''' 49 | if self.mention: 50 | msg = self._remove_bot_id(msg, self.bot_id) 51 | if not self.grammar or not msg: 52 | # No grammar rule or or no mention of the bot, Just return msg 53 | return msg 54 | try: 55 | regex = re.compile(r"{}".format(self.grammar)) 56 | except re.error as regex_error: 57 | fail_unless(False, "The grammar expression '{}' has an error : {}".format(self.grammar, regex_error)) 58 | 59 | return regex.match(msg) 60 | 61 | @staticmethod 62 | def _filter(items, return_field, filter_field, filter_value): 63 | '''Return 'return_field' if filter_value in items filter_field else return none''' 64 | for item in items: 65 | if filter_value == item.get(filter_field, None): 66 | return item.get(return_field, None) 67 | return None 68 | 69 | def _get_channel_group_id(self, channel_type): 70 | items = self._call_api("{}.list".format(channel_type), exclude_members=1) 71 | return self._filter(items[channel_type], "id", "name", self.channel) 72 | 73 | def _get_channel_group_info(self): 74 | '''Return ID and type of channel (channel|groups)''' 75 | channel_id = self._get_channel_group_id("groups") 76 | if channel_id: 77 | return channel_id, "groups" 78 | channel_id = self._get_channel_group_id("channels") 79 | return channel_id, "channels" 80 | -------------------------------------------------------------------------------- /src/tests/test_check.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from unittest import TestCase 4 | 5 | import mock 6 | 7 | import check_op 8 | 9 | 10 | class CheckTest(TestCase): 11 | 12 | @mock.patch('check_op.Check._filter') 13 | @mock.patch('check_op.Check._get_channel_group_info') 14 | @mock.patch('check_op.Check._call_api') 15 | def setUp(self, mock_call_api, mock_get_channel_group_info, mock_filter): 16 | 17 | self.script_dir = os.path.dirname(__file__) 18 | with open('{}/responses/users.json'.format(self.script_dir)) as f: 19 | mock_call_api.return_value = json.load(f) 20 | mock_get_channel_group_info.return_value = "C024BE91L", "channels" 21 | mock_filter.return_value = "U01B12FDS" 22 | 23 | self.grammar = "^(superApp)\s+(deploy)\s+(live|staging)\s+(\S+)($|\s+)" 24 | self.resource = check_op.Check(token="token", channel="testChannel", bot="theBender", 25 | grammar=self.grammar, path="bender_path", reply="testing 1.2.3") 26 | def test__init__(self): 27 | self.assertEqual(self.resource.checked_msg, []) 28 | 29 | @mock.patch('check_op.Check._call_api') 30 | def test_check_logic_unread_no_msg(self, mock_call_api): 31 | mock_call_api.return_value = {"ok": True, "latest": "1358547726.000003", "messages": [], "has_more": False, 32 | "unread_count_display": 0} 33 | mark_ts = self.resource.check_logic_unread() 34 | mock_call_api.assert_called() 35 | self.assertFalse(mark_ts) 36 | 37 | # # Not working yet 38 | # self.resource.check_output() 39 | # self.assertTrue(mock_print.called) 40 | # mock_print.assert_called_with('[]') 41 | 42 | # self.resource.checked_msg = [{"id_ts": "1358546512.010007", "v": "2"}, 43 | # {"id_ts": "1328546512.010007", "v": "0"}, 44 | # {"id_ts": "1328546512.110007", "v": "1"}] 45 | 46 | # self.resource.check_output() 47 | # mock_print.assert_called() 48 | # self.assertItemsEqual([{"id_ts": "1328546512.010007", "v": "0"}, 49 | # {"id_ts": "1328546512.110007", "v": "1"}, 50 | # {"id_ts": "1358546512.010007", "v": "2"}], self.resource.checked_msg) 51 | 52 | 53 | # @mock.patch('base.Base._call_api') 54 | # def test_check_logic_unread_many(self, mock_call_api): 55 | # with open('{}/responses/messages_many.json'.format(self.script_dir)) as json_file: 56 | # mock_call_api.return_value = json.load(json_file) 57 | 58 | # mark_ts = self.resource.check_logic_unread() 59 | # mock_call_api.assert_called() 60 | # self.assertFalse(mark_ts) 61 | 62 | @mock.patch('check_op.json.dumps') 63 | @mock.patch('check_op.print', create=True) 64 | def test_check_output(self, mock_print, mock_json_dumps): 65 | self.resource.checked_msg = [1, 2, 3] 66 | # Simply return x 67 | mock_json_dumps.side_effect = lambda x, indent=0, sort_keys=0: x 68 | self.resource.check_output() 69 | mock_json_dumps.assert_called() 70 | mock_print.assert_called_with([1, 2, 3]) 71 | 72 | @mock.patch('check_op.Check._call_api') 73 | def test_mark_read(self, mock_call_api): 74 | self.resource._mark_read("123") 75 | self.assertTrue(mock_call_api.called) 76 | mock_call_api.assert_called_with( 77 | 'channels.mark', channel='C024BE91L', ts='123') 78 | 79 | 80 | @mock.patch('check_op.Check._msg_grammar') 81 | def test_filter_msgs(self, mock_msg_grammer): 82 | with open('{}/responses/messages.json'.format(self.script_dir)) as json_file: 83 | messages = json.load(json_file) 84 | # Skipping return of message type not "message" so our index is less then actual message. 85 | mock_msg_grammer.side_effect = [False, messages["messages"][2], False, messages["messages"][5], False] 86 | self.resource._filter_msgs(messages["messages"], len(messages["messages"])) 87 | check_msgs = [{'id_ts': u'1358546512.010007'}, {'id_ts': u'1358546515.010007'}] 88 | self.assertItemsEqual(check_msgs, self.resource.checked_msg) 89 | -------------------------------------------------------------------------------- /src/lib/check_op.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' The Check operation for bender resource''' 3 | from __future__ import print_function 4 | 5 | import json 6 | 7 | from payload import PayLoad 8 | from functions import list_get 9 | from base import Base 10 | 11 | 12 | class Check(Base): 13 | ''' Check resource''' 14 | def __init__(self, **kwargs): 15 | Base.__init__(self, **kwargs) 16 | self.checked_msg = [] 17 | 18 | def _mark_read(self, timestamp): 19 | self._call_api(self.channel_type + ".mark", 20 | channel=self.channel_id, 21 | ts=timestamp) 22 | 23 | def _filter_msgs(self, messages, index): 24 | ''' Filter messages by type 'message' and regex match grammar''' 25 | for message in messages[:index]: 26 | if message.get("type") == "message" and self._msg_grammar(message.get("text")): 27 | self.checked_msg.append({"id_ts": message["ts"]}) 28 | 29 | def check_logic_unread(self, max_api_count=1000, limit=5): 30 | """Concourse resource `check` logic using unread mark by slack""" 31 | unread_counter = True 32 | latest_ts = 0 33 | while unread_counter or limit >= 0: 34 | messages = self._call_api(self.channel_type + ".history", 35 | channel=self.channel_id, 36 | unreads=True, 37 | count=max_api_count, 38 | latest=latest_ts) 39 | limit -= 1 40 | if latest_ts == 0: 41 | unread_counter = messages.get("unread_count_display") 42 | mark_ts = list_get(messages["messages"], 0, {}).get("ts", False) 43 | latest_ts = list_get(messages["messages"], -1, {}).get("ts", 0) 44 | if messages["messages"] and 0 < unread_counter < max_api_count: 45 | self._filter_msgs(messages["messages"], unread_counter) 46 | unread_counter = 0 47 | elif messages["messages"] and unread_counter >= max_api_count: 48 | self._filter_msgs(messages["messages"], unread_counter) 49 | unread_counter -= max_api_count 50 | 51 | if mark_ts: 52 | # Mark that we read all unread msgs 53 | self._mark_read(mark_ts) 54 | 55 | if not self.checked_msg: 56 | # Sort messages by 'ts' chronologically 57 | self.checked_msg = sorted(self.checked_msg, key=lambda k: k['id_ts']) 58 | 59 | def check_logic_concourse(self, max_api_count=1000, limit=5): 60 | """Concourse resource `check` logic using version passed by concourse.""" 61 | oldest = self.version.get("id_ts", 0) 62 | has_more = True 63 | while has_more and limit >= 0: 64 | messages = self._call_api(self.channel_type + ".history", 65 | channel=self.channel_id, 66 | count=max_api_count, 67 | oldest=oldest) 68 | limit -= 1 69 | has_more = messages.get("has_more") 70 | if messages["messages"]: 71 | oldest = messages["messages"][-1]["ts"] 72 | self._filter_msgs(messages["messages"], len(messages["messages"])) 73 | 74 | if not self.checked_msg: 75 | # Sort messages by 'ts' chronologically 76 | self.checked_msg = sorted(self.checked_msg, key=lambda k: k['id_ts']) 77 | if not self.version.get("id_ts"): 78 | # if we don't have version passed. So report latest only 79 | try: 80 | self.checked_msg = [self.checked_msg[0]] 81 | except IndexError: 82 | self.checked_msg = [] 83 | 84 | def check_output(self): 85 | """Concourse resource `check` output """ 86 | print(json.dumps(self.checked_msg, indent=4, sort_keys=True)) 87 | 88 | def main(): 89 | """Concourse resource `check` main """ 90 | payload = PayLoad() 91 | slack_client = Check(**payload.args) 92 | if slack_client.slack_unread: 93 | slack_client.check_logic_unread() 94 | else: 95 | slack_client.check_logic_concourse() 96 | slack_client.check_output() 97 | 98 | if __name__ == '__main__': 99 | main() 100 | -------------------------------------------------------------------------------- /src/lib/out_op.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' The OUT operation for bender resource''' 3 | from __future__ import print_function 4 | 5 | import json 6 | 7 | from payload import PayLoad 8 | from base import Base 9 | from functions import fail_unless, template_with_regex, read_if_exists, read_content_from_file 10 | 11 | class Out(Base): # pylint: disable=too-few-public-methods,too-many-instance-attributes 12 | ''' Out resource''' 13 | 14 | def __init__(self, **kwargs): 15 | Base.__init__(self, **kwargs) 16 | self.metadata = [] 17 | self.path = kwargs.get("path") 18 | self.reply_attachments = read_if_exists(self.working_dir, kwargs.get("reply_attachments")) 19 | self.reply = read_if_exists(self.working_dir, kwargs.get("reply")) 20 | self.reply_thread = kwargs.get("reply_thread", True) 21 | # as_user info 22 | self.as_user = kwargs.get("as_user") 23 | self.bot_icon_emoji = kwargs.get("bot_icon_emoji") 24 | self.bot_icon_url = kwargs.get("bot_icon_url") 25 | # Get context from original message 26 | context_json_path = '{}/{}/bender.json'.format(self.working_dir, self.path) 27 | context_content = read_content_from_file(context_json_path) 28 | context_content = self.load_json(context_content) 29 | # Initialize from original message 30 | self.version = context_content["version"] 31 | self.metadata = context_content["metadata"] 32 | self.original_msg = context_content["original_msg"] 33 | 34 | @staticmethod 35 | def load_json(json_string): 36 | ''' de-serialize a JSON strong into a python object''' 37 | try: 38 | serialized = json.loads(json_string) 39 | except ValueError as value_error: 40 | fail_unless(False, "JSON Input error: {}".format(value_error)) 41 | return serialized 42 | 43 | def _reply(self, thread_timestamp=False, text=False, attachments=False): 44 | args = {} 45 | if thread_timestamp: 46 | args.update({"thread_ts": thread_timestamp}) 47 | if text: 48 | args.update({"text": text}) 49 | if attachments: 50 | args.update({"attachments": attachments}) 51 | if not self.as_user: 52 | args.update({"as_user": False, "username": self.bot}) 53 | if self.bot_icon_emoji: 54 | args.update({"icon_emoji": self.bot_icon_emoji}) 55 | elif self.bot_icon_url: 56 | args.update({"icon_url": self.bot_icon_url}) 57 | 58 | self._call_api("chat.postMessage", 59 | channel=self.channel_id, 60 | parse="full", 61 | **args) 62 | 63 | def out_logic(self): 64 | """Concourse resource `out` logic """ 65 | 66 | regex = self._msg_grammar(self.original_msg) 67 | user = "" 68 | for item in self.metadata: 69 | if item["name"] == 'User': 70 | user = item["value"] 71 | break 72 | if self.reply: 73 | self.reply = template_with_regex(self.reply, regex, user=user) 74 | if self.reply_attachments: 75 | self.reply_attachments = template_with_regex(self.reply_attachments, regex, user=user) 76 | self.reply_attachments = self.load_json(self.reply_attachments) 77 | if self.reply_thread: 78 | self.reply_thread = self.version["id_ts"] 79 | 80 | self._reply(thread_timestamp=self.reply_thread, 81 | text=self.reply, 82 | attachments=self.reply_attachments) 83 | 84 | def out_output(self): 85 | """Concourse resource `out` output """ 86 | output = {"version": self.version, "metadata": self.metadata} 87 | print(json.dumps(output, indent=4, sort_keys=True)) 88 | 89 | 90 | def main(): 91 | ''' Main `out` entry point''' 92 | payload = PayLoad() 93 | fail_unless(payload.args.get("path"), "path is required, but not defined.") 94 | if not payload.args.get("reply") and not payload.args.get("reply_attachments"): 95 | fail_unless(False, "'reply' or 'reply_attachments' paramater required, but not defined.") 96 | 97 | slack_client = Out(**payload.args) 98 | slack_client.out_logic() 99 | slack_client.out_output() 100 | 101 | if __name__ == '__main__': 102 | main() 103 | -------------------------------------------------------------------------------- /src/tests/test_functions.py: -------------------------------------------------------------------------------- 1 | ''' Testing functions ''' 2 | import os 3 | import re 4 | from unittest import TestCase 5 | 6 | from functions import * 7 | import mock 8 | 9 | class FunctionsTest(TestCase): 10 | 11 | @mock.patch('functions.template_str') 12 | def test_template_with_regex(self, mock_template_str): 13 | # Simply return t, e 14 | mock_template_str.side_effect = lambda t, e: (t, e) 15 | # Test subgroups 16 | regex = re.compile(r"^(Bender)\s(Rodriguez)") 17 | regex = regex.match("Bender Rodriguez") 18 | text, env = template_with_regex("STR", regex) 19 | self.assertEqual("STR", text) 20 | self.assertEqual({'regex': ('Bender', 'Rodriguez')}, env) 21 | # Test named subgroups 22 | regex = re.compile(r"^(?PBender)\s(?PRodriguez)") 23 | regex = regex.match("Bender Rodriguez") 24 | text, env = template_with_regex("STR2", regex) 25 | self.assertEqual("STR2", text) 26 | self.assertEqual({'regex': {'FIRST': 'Bender', 'LAST': 'Rodriguez'}}, env) 27 | 28 | @mock.patch('functions.fail_unless') 29 | def test_template_str_simple(self, mock_fail_unless): 30 | # Simple template no variables "pass through" 31 | string = "Hi Man" 32 | templated_string = template_str(string, {}) 33 | self.assertEqual("Hi Man", templated_string) 34 | mock_fail_unless.assert_not_called() 35 | 36 | # Simple templating using variables 37 | string = "Hi {{ Title }}. {{ Name }} status: {{ Status | string }}" 38 | variables = {"Title": "Mr", "Name": "Adham", "Status": True} 39 | templated_string = template_str(string, variables) 40 | self.assertEqual("Hi Mr. Adham status: True", templated_string) 41 | mock_fail_unless.assert_not_called() 42 | 43 | @mock.patch('functions.fail_unless') 44 | def test_template_str_environment_variables(self, mock_fail_unless): 45 | # Check merge with os.ENV 46 | os.environ["BUILD_NUM"] = "10" 47 | string = "http//127.0.0.1/build?name={{build_name}}&num={{ ENV['BUILD_NUM'] }}" 48 | variables = {"build_name": "bender"} 49 | templated_string = template_str(string, variables) 50 | self.assertEqual("http//127.0.0.1/build?name=bender&num=10", templated_string) 51 | mock_fail_unless.assert_not_called() 52 | 53 | @mock.patch('functions.fail_unless') 54 | def test_template_str_error(self, mock_fail_unless): 55 | # Template error 56 | string = "Hi {{ Title }}. {{ Name" 57 | no_return = template_str(string, {}) 58 | mock_fail_unless.assert_called() 59 | self.assertIsNone(no_return) 60 | # Undefined variable error 61 | string = "Hi {{ Title }}. {{ Name}}" 62 | no_return = template_str(string, {"Title": "Mr."}) 63 | mock_fail_unless.assert_called() 64 | self.assertIsNone(no_return) 65 | # Undefined OS env 66 | string = "http//127.0.0.1/build?date={{ ENV['BUILD_DATE'] }}" 67 | no_return = template_str(string, {}) 68 | mock_fail_unless.assert_called() 69 | self.assertIsNone(no_return) 70 | 71 | @mock.patch('functions.print', create=True) 72 | def test_fail_unless(self, mock_print): 73 | fail_unless(True, "Not") 74 | mock_print.assert_not_called() 75 | 76 | with self.assertRaises(SystemExit) as value_error: 77 | fail_unless(False, "FIRE") 78 | mock_print.assert_called() 79 | self.assertEqual(value_error.exception.code, 1) 80 | ## Can't manage to check one argument only :( 81 | #mock_print.assert_called_with("FIRE", file=mock.ANY, mode=mock.ANY) 82 | 83 | @mock.patch('functions.fail_unless') 84 | def test_read_if_exists(self, mock_fail_unless): 85 | base_path = "/Bcbc/x" 86 | content = "SOMETHING" 87 | return_val = read_if_exists(base_path, content) 88 | self.assertEqual(return_val, "SOMETHING") 89 | mock_fail_unless.assert_not_called() 90 | 91 | content = "content1\ncontent2\n" 92 | return_val = read_if_exists(os.path.dirname(__file__), "responses/file.txt") 93 | mock_fail_unless.assert_not_called() 94 | self.assertEqual(return_val, content) 95 | 96 | def test_list_get(self): 97 | self.assertEqual(list_get([0,1,2], 1, True), 1) 98 | self.assertTrue(list_get([0,1,2], -1, True)) 99 | self.assertIsNone(list_get([0,1,2], 4)) 100 | -------------------------------------------------------------------------------- /src/tests/test_payload.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import TestCase 3 | 4 | import mock 5 | 6 | import payload 7 | 8 | 9 | class PayloadTest(TestCase): 10 | 11 | @mock.patch('payload.os.path.isdir') 12 | @mock.patch('payload.fail_unless') 13 | @mock.patch('payload.PayLoad._get_payload') 14 | def test_get_dir_from_argv(self, mock_get_payload, mock_fail_unless, mock_os_path): 15 | sys.argv = ["a"] 16 | argv_return = payload.PayLoad._get_dir_from_argv() 17 | mock_fail_unless.assert_not_called() 18 | self.assertFalse(argv_return) 19 | 20 | sys.argv = ["a", "b"] 21 | mock_os_path.return_value = False 22 | argv_return = payload.PayLoad._get_dir_from_argv() 23 | mock_fail_unless.assert_called() 24 | 25 | mock_fail_unless.reset_mock() 26 | sys.argv = ["a", "/x"] 27 | mock_os_path.return_value = True 28 | argv_return = payload.PayLoad._get_dir_from_argv() 29 | self.assertEqual(argv_return, "/x") 30 | 31 | @mock.patch('payload.fail_unless') 32 | @mock.patch('payload.PayLoad._get_payload') 33 | def test_parse_payload_no_source(self, mock_get_payload, mock_fail_unless): 34 | mock_get_payload.side_effect = [{}] 35 | payload.PayLoad() 36 | mock_fail_unless.assert_called() 37 | 38 | @mock.patch('payload.fail_unless') 39 | @mock.patch('payload.PayLoad._get_payload') 40 | def test_parse_payload_empty_source(self, mock_get_payload, mock_fail_unless): 41 | mock_get_payload.side_effect = [{'source': {}}] 42 | payload.PayLoad() 43 | mock_fail_unless.assert_called() 44 | 45 | @mock.patch('payload.fail_unless') 46 | @mock.patch('payload.PayLoad._get_payload') 47 | def test_parse_payload_no_mandatory(self, mock_get_payload, mock_fail_unless): 48 | mock_get_payload.side_effect = [{'source': {"slack_token": "slack_token"}}] 49 | payload.PayLoad() 50 | mock_fail_unless.assert_called() 51 | 52 | @mock.patch('payload.PayLoad._get_dir_from_argv') 53 | @mock.patch('payload.fail_unless') 54 | @mock.patch('payload.PayLoad._get_payload') 55 | def test_parse_payload_defaults(self, mock_get_payload, mock_fail_unless, mock_get_dir_from_argv): 56 | mock_get_dir_from_argv.return_value = "/tmp" 57 | mock_get_payload.side_effect = [{'source': {"slack_token": "slack_token", "channel": "channel"}}] 58 | py = payload.PayLoad() 59 | mock_fail_unless.assert_not_called() 60 | self.assertEqual(py.args["slack_token"], "slack_token") 61 | self.assertEqual(py.args["channel"], "channel") 62 | self.assertEqual(py.args["template_filename"], "template_file.txt") 63 | self.assertEqual(py.args["version"], {}) 64 | self.assertFalse(py.args["slack_unread"]) 65 | self.assertTrue(py.args['mention']) 66 | self.assertTrue(py.args['as_user']) 67 | self.assertIsNone(py.args["bot_icon_emoji"]) 68 | self.assertIsNone(py.args["bot_icon_url"]) 69 | self.assertIsNone(py.args["template"]) 70 | self.assertIsNone(py.args["grammar"]) 71 | self.assertIsNone(py.args["path"]) 72 | self.assertIsNone(py.args["reply"]) 73 | self.assertTrue(py.args["reply_thread"]) 74 | 75 | @mock.patch('payload.PayLoad._get_dir_from_argv') 76 | @mock.patch('payload.fail_unless') 77 | @mock.patch('payload.PayLoad._get_payload') 78 | def test_parse_payload_values(self, mock_get_payload, mock_fail_unless, mock_get_dir_from_argv): 79 | mock_get_dir_from_argv.return_value = "/tmp" 80 | mock_get_payload.side_effect = [{'params': {'path': 'path', 'reply': 'reply', 'reply_attachments': 'reply_attachments', 81 | 'reply_thread': 'reply_thread'}, 82 | 'source': {'bot_name': 'bot_name', 83 | 'channel': 'channel', 84 | 'grammar': 'grammar', 85 | 'slack_token': 'slack_token', 86 | 'template': 'template', 87 | 'mention': 'mention', 88 | 'as_user': 'as_user', 89 | 'bot_icon_emoji': 'bot_icon_emoji', 90 | 'bot_icon_url': 'bot_icon_url', 91 | 'template_filename': 'template_filename', 92 | 'slack_unread': True}, 93 | 'version': 'version'}] 94 | py = payload.PayLoad() 95 | mock_fail_unless.assert_not_called() 96 | self.assertEqual(py.args["slack_token"], "slack_token") 97 | self.assertEqual(py.args["channel"], "channel") 98 | self.assertEqual(py.args["template_filename"], "template_filename") 99 | self.assertTrue(py.args["slack_unread"]) 100 | 101 | self.assertEqual(py.args["mention"], "mention") 102 | self.assertEqual(py.args["as_user"], "as_user") 103 | self.assertEqual(py.args["bot_icon_emoji"], "bot_icon_emoji") 104 | self.assertEqual(py.args["bot_icon_url"], "bot_icon_url") 105 | 106 | self.assertEqual(py.args["template"], "template") 107 | self.assertEqual(py.args["version"], "version") 108 | self.assertEqual(py.args["grammar"], "grammar") 109 | self.assertEqual(py.args["path"], "path") 110 | self.assertEqual(py.args["reply"], "reply") 111 | self.assertEqual(py.args["reply_attachments"], "reply_attachments") 112 | self.assertEqual(py.args["reply_thread"], "reply_thread") 113 | -------------------------------------------------------------------------------- /src/tests/test_base.py: -------------------------------------------------------------------------------- 1 | ''' Testing base class''' 2 | import json 3 | import os 4 | import re 5 | from unittest import TestCase 6 | 7 | import mock 8 | 9 | import base 10 | 11 | 12 | class BaseTest(TestCase): 13 | 14 | @mock.patch('base.Base._filter') 15 | @mock.patch('base.Base._get_channel_group_info') 16 | @mock.patch('base.Base._call_api') 17 | def setUp(self, mock_call_api, mock_get_channel_group_info, mock_filter): 18 | 19 | self.script_dir = os.path.dirname(__file__) 20 | with open('{}/responses/users.json'.format(self.script_dir)) as user_json_file: 21 | mock_call_api.return_value = json.load(user_json_file) 22 | mock_get_channel_group_info.return_value = "C024BE91L", "channels" 23 | mock_filter.return_value = "U01B12FDS" 24 | 25 | self.resource = base.Base(token="token", 26 | channel="testChannel", 27 | bot_name="theBender", 28 | working_dir="/test", 29 | grammar="grammar", 30 | path="bender_path", 31 | reply="testing 1.2.3", 32 | slack_unread=True) 33 | 34 | def test__init__(self): 35 | # Channel info 36 | self.assertEqual(self.resource.channel, "testChannel") 37 | self.assertEqual(self.resource.channel_id, "C024BE91L") 38 | self.assertEqual(self.resource.channel_type, "channels") 39 | # Bot info 40 | self.assertEqual(self.resource.bot, "theBender") 41 | # Misc 42 | self.assertEqual(self.resource.grammar, "grammar") 43 | self.assertTrue(self.resource.slack_unread) 44 | self.assertEqual(self.resource.working_dir, "/test") 45 | 46 | @mock.patch('base.fail_unless') 47 | @mock.patch('base.Base._filter') 48 | @mock.patch('base.Base._get_channel_group_info') 49 | @mock.patch('base.Base._call_api') 50 | def test_mandatory(self, mock_call_api, mock_get_channel_group_info, mock_filter, mock_fail_unless): 51 | def fake_fail_unless(c, m): 52 | if not c: 53 | raise OSError 54 | self.script_dir = os.path.dirname(__file__) 55 | with open('{}/responses/users.json'.format(self.script_dir)) as user_json_file: 56 | mock_call_api.return_value = json.load(user_json_file) 57 | mock_get_channel_group_info.return_value = "C024BE91L", "channels" 58 | mock_filter.return_value = "U01B12FDS" 59 | mock_fail_unless.side_effect = fake_fail_unless 60 | 61 | self.assertRaises(OSError, base.Base, token="token", 62 | channel="testChannel", 63 | bot_name="theBender", 64 | mention=False, 65 | grammar=False) 66 | 67 | def test_remove_bot_id(self): 68 | bot_id = "U01B12FDS" 69 | self.assertEqual("", self.resource._remove_bot_id("<@U01B12FDS> ", bot_id)) 70 | self.assertEqual("", self.resource._remove_bot_id(" <@U01B12FDS>", bot_id)) 71 | self.assertEqual("", self.resource._remove_bot_id(" <@U01B12FDS> ", bot_id)) 72 | self.assertEqual("Hi", self.resource._remove_bot_id("<@U01B12FDS> Hi ", bot_id)) 73 | self.assertIsNone(self.resource._remove_bot_id("@U01B12FDS Hi", bot_id)) 74 | self.assertIsNone(self.resource._remove_bot_id("< @U01B12FDS > Hi", bot_id)) 75 | 76 | def test_msg_grammar_with_mention(self): 77 | self.resource.bot_id = "U01B12FDS" 78 | self.resource.mention = True 79 | # Check mention msg 80 | self.resource.grammar = False 81 | self.assertIsNone(self.resource._msg_grammar("@U01B12FDS Hi")) 82 | self.assertIsNotNone(self.resource._msg_grammar("<@U01B12FDS> superApp deploy live 1.2")) 83 | # Check with grammar 84 | self.resource.grammar = "^(superApp)\s+(deploy)\s+(live|staging)\s+(\S+)($|\s+)" 85 | self.assertIsNone(self.resource._msg_grammar("theBender superApp deploy live 1.2")) 86 | self.assertIsNotNone(self.resource._msg_grammar("<@U01B12FDS> superApp deploy live 1.2")) 87 | self.assertIsNone(self.resource._msg_grammar("<@U01B12FDS> superApp2 deploy dev 1.1 extra")) 88 | 89 | def test_msg_grammar_without_mention(self): 90 | self.resource.mention = False 91 | self.resource.grammar = "^(calculon)\s+(superApp)\s+(deploy)\s+(live|staging)\s+(\S+)($|\s+)" 92 | self.assertIsNone(self.resource._msg_grammar("calculon superApp deploy dev 1.2")) 93 | self.assertIsNotNone(self.resource._msg_grammar("calculon superApp deploy live 1.2")) 94 | self.assertIsNone(self.resource._msg_grammar("<@calculon> superApp2 deploy dev 1.1 extra")) 95 | 96 | 97 | def test_filter(self): 98 | items = [{"Id": 1, "Name": "One"}, {"Id": 2, "Name": "Two"}, {"Id": 3, "Name": "Three"}] 99 | self.assertEqual("One", self.resource._filter(items, "Name", "Id", 1)) 100 | self.assertEqual(1, self.resource._filter(items, "Id", "Name", "One")) 101 | # Non existing filter value 102 | self.assertIsNone(self.resource._filter(items, "Id", "Name", "Five")) 103 | # Non existing filter field 104 | self.assertIsNone(self.resource._filter(items, "Id", "Type", "String")) 105 | # Non existing return field 106 | self.assertIsNone(self.resource._filter(items, "Type", "Name", "One")) 107 | 108 | 109 | @mock.patch('base.fail_unless') 110 | @mock.patch('slackclient.SlackClient.api_call') 111 | def test_call_api(self, mock_slackclient, mock_fail_unless): 112 | mock_fail_unless.return_value = "" 113 | # API returned true 114 | mock_slackclient.return_value = {"ok": True} 115 | self.resource._call_api("bogus.call", arg1="val1", arg2="val2") 116 | mock_slackclient.assert_called_with('bogus.call', arg1="val1", arg2="val2") 117 | self.assertTrue(mock_slackclient.called) 118 | -------------------------------------------------------------------------------- /examples.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | ## `Example 1`: A simple trigger when `@bender` is mentioned in `planet_express` channel 4 | 5 | ``` yaml 6 | resources : 7 | - name : bender 8 | type : bender-resource 9 | source : 10 | slack_token : ((token)) 11 | channel : "planet_express" 12 | jobs : 13 | - name : Simple trigger 14 | plan : 15 | - get: bender 16 | version: every 17 | trigger: true 18 | 19 | - task : This job will be triggered 20 | file: something/task.yml 21 | ``` 22 | 23 | ## `Example 2`: A simple trigger when `@bender` is mentioned in `planet_express` channel and reply on status 24 | 25 | ``` yaml 26 | resources : 27 | - name : bender 28 | type : bender-resource 29 | source : 30 | slack_token : ((token)) 31 | channel : "planet_express" 32 | jobs : 33 | - name : Simple trigger 34 | plan : 35 | - get: bender 36 | version: every 37 | trigger: true 38 | - put: bender 39 | params: 40 | path: "bender" 41 | reply: "Start job." 42 | - task : This job will be triggered 43 | file: something/task.yml 44 | on_success: 45 | put: bender 46 | params: 47 | path: "bender" 48 | reply: "Done with job." 49 | on_failure: 50 | put: bender 51 | params: 52 | path: "bender" 53 | reply: "Sorry job failed." 54 | ``` 55 | 56 | ## `Example 3`: Simple deploy with some regex 57 | 58 | We want to be able to deploy `@bender superApp deploy` 59 | 60 | ``` yaml 61 | resources : 62 | - name : bender 63 | type : bender-resource 64 | source : 65 | slack_token : ((token)) 66 | channel : "planet_express" 67 | # Note we escape \ with \\ 68 | grammar : "^(superApp)\\s+(deploy)($|\\s+)" 69 | jobs : 70 | - name : Simple deploy 71 | plan : 72 | - get: bender 73 | version: every 74 | trigger: true 75 | 76 | - task : Deploy superApp 77 | file: something/deploy.yml 78 | ``` 79 | 80 | ## `Example 4`: Realistic deploy scenario with some regex and subgroups 81 | 82 | ```yaml 83 | resources : 84 | - name : bender 85 | type : bender-resource 86 | source : 87 | slack_token : ((token)) 88 | channel : "planet_express" 89 | # Note we escape \ with \\ 90 | grammar : "^(superApp)\\s+(deploy)\\s+(live|staging)\\s+(\\S+)($|\\s+)" 91 | # create key=value file 92 | template : "ENVIRONMENT={{ regex[2] }}\nVERSION={{ regex[3] }}\n" 93 | 94 | jobs : 95 | - name : Hello 96 | plan : 97 | - get: bender 98 | version: every 99 | trigger: true 100 | 101 | - put: bender 102 | params: 103 | path: "bender" 104 | reply: "Starting deployment" 105 | 106 | - task : Deploying something 107 | config : 108 | platform : linux 109 | image_resource : 110 | type : docker-image 111 | source : {repository: quay.io/hellofresh/ci-ansible} 112 | inputs : 113 | - name : bender 114 | run : 115 | path : /bin/sh 116 | args : 117 | - -exc 118 | - | 119 | # source our template file 120 | . bender/template_file.txt 121 | # Deploy with passed in argument 122 | deploy_command -i ${ENVIRONMENT} -v ${VERSION} 123 | ``` 124 | 125 | ## `Example 5`: Realistic deploy scenario with some regex and named subgroups 126 | 127 | Same as Example 4 with the following difference `grammar: "^(?PsuperApp)\\s+(?Pdeploy)\\s+(?Plive|staging)\\s+(?P\\S+)($|\\s+)"` and `template: "ENVIRONMENT={{ regex[2] }}\nVERSION={{ regex[3] }}\n"` 128 | 129 | ## `Example 6`: Using `reply_attachments` 130 | 131 | You can provide a json file in your source code with slack attachments. 132 | 133 | ```json 134 | [ 135 | { 136 | "fallback": "Deploying {{ regex['environment'] }} {{ regex['app'] }} {{ regex['version'] }}", 137 | "title": "Deploying {{ regex['app'] }}", 138 | "title_link": "{{ENV['ATC_EXTERNAL_URL']}}/teams/{{ ENV['BUILD_TEAM_NAME'] }}/pipelines/{{ENV['BUILD_PIPELINE_NAME']}}/jobs/{{ENV['BUILD_JOB_NAME']}}/builds/{{ENV['BUILD_NAME']}}", 139 | "text": "Deploying {{ regex['environment'] }} {{ regex['app'] }} {{ regex['version'] }}", 140 | "color": "#7CD197" 141 | } 142 | ] 143 | ``` 144 | 145 | Snippet of the deployment pipeline. 146 | 147 | ```yaml 148 | - get: bender 149 | version: every 150 | trigger: true 151 | 152 | - get: source_code 153 | 154 | - put: bender 155 | params: 156 | path: "bender" 157 | reply_attachments: "source_code/..../start_deployment.json" 158 | 159 | # you can also encode it as string 160 | - put: bender 161 | params: 162 | path: "bender" 163 | reply_attachments: "[{\"title\": \"Yeah\",\"text\": \"Deployment {{ regex['version'] }} :white_check_mark:\"}]" 164 | 165 | ``` 166 | 167 | ## `Example 7`: Bumping versions 168 | 169 | ```yaml 170 | resources : 171 | - name : semantic-version 172 | ... 173 | - name : bender 174 | type : bender-resource 175 | source : 176 | slack_token : ((token)) 177 | channel : "planet_express" 178 | # Note we escape \ with \\ 179 | grammar : "^(superApp)\\s+(release)\\s+(minor)($|\\s+)" 180 | 181 | jobs : 182 | - name : Do minor release 183 | plan : 184 | - get: bender 185 | version: every 186 | trigger: true 187 | - put: semantic-version 188 | params: 189 | bump: minor 190 | - put: bender 191 | params: 192 | path: "bender" 193 | reply: "Release " 194 | ``` 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bender concourse resource 2 | 3 | [![Build Status](https://travis-ci.org/ahelal/bender.svg?branch=master)](https://travis-ci.org/ahelal/bender) [![Docker Repository on Quay](https://quay.io/repository/ahelal/bender/status "Docker Repository on Quay")](https://quay.io/repository/ahelal/bender) 4 | 5 | A Concourse resource that can trigger any job (deployments, releases, ...) using slack. 6 | 7 | Check [examples](examples.md) page for usage. 8 | 9 | *NOTE: Bender is still young, Your input and contribution is highly appreciated.* 10 | 11 | ## Deploying to Concourse 12 | 13 | You can use the docker image by defining the [resource type](https://concourse.ci/configuring-resource-types.html) in your pipeline YAML. 14 | 15 | ```yaml 16 | resource_types : 17 | - name: bender-resource 18 | type: docker-image 19 | source: 20 | repository: quay.io/ahelal/bender 21 | tag: latest 22 | ``` 23 | 24 | ## Source Configuration 25 | 26 | * `slack_token`: *Required*. The slack token to be used. For more info check [slack documentation](https://api.slack.com/docs/token-types#bot). 27 | 28 | * `channel`: *Required*. The channel name to be used by the bot. 29 | 30 | * `bot_name`: *Optional*, *default `bender`*. The bot name will be used to identify and filter messages. All messages must be addressed to the bot, eg.: `@bot_name some message`. 31 | 32 | * `as_user`: *Optional.*, *default `true`*. By default use the authed user, if `false` you can customize the `bot_name`, `bot_icon_emoji` and `bot_icon_ur`. 33 | 34 | * `bot_icon_emoji`: *Optional.* Emoji to use as the icon for this message. Overrides `bot_icon_url`. Must be used in conjunction with `as_user` set to false, otherwise ignored. 35 | 36 | * `bot_icon_url`: *Optional.* URL to an image to use as the icon for this message. Must be used in conjunction with `as_user` set to false, otherwise ignored. 37 | 38 | * `mention`: *Optional.* Only respond to mention `@bot_name` this will only work with `as_user: false`, otherwise ignored. 39 | 40 | * `grammar`: *Optional.* If not defined bender will respond to all mentions `@bot_name` and If grammar is defined bender will **only** respond to messages matching the regex expression. Use [python regular expression](https://docs.python.org/2/library/re.html) syntax. See [examples](examples.md) page for inspiration. 41 | 42 | * `template`: *Optional*. A **string** that will be evaluated and written to `template_filename` can be used as an input file for further jobs in the pipeline. 43 | 44 | * `template_filename`: *Optional*, *default `template_file.txt`*. The file name for a generated template. 45 | 46 | * `slack_unread`: *Optional*, *default `false`*. If set to true, The state of **slack unread message** will be used instead of of the last version reported by concourse. This will improve speed, but downside you can't have multiple triggers per channel with same token and it's non-standard concourse behavior. This only affects the check method. 47 | 48 | ## Behavior 49 | 50 | ### `check`: Check for new messages that match the rules 51 | 52 | Check will report a new version;`timestamp`; if message fits **all** the criteria. 53 | 54 | * If `mention` is *true* the message must be addressed to `@bot_name` in the selected `channel`. 55 | 56 | * If `grammar` is defined, it *must* match the regular expression defined in `grammar`. 57 | 58 | #### `check Parameters` 59 | 60 | Check accepts no params. 61 | 62 | ### `in`: Get the message 63 | 64 | A file **bender.json** will be created with the message payload. 65 | 66 | if `template` is defined it will be evaluated and written to `template_filename`. For more info on template syntax read [template section](#template). For example on usage, check the [examples](examples.md) page. 67 | 68 | #### `in Parameters` 69 | 70 | In accepts no params. 71 | 72 | ### `out`: Reply to original message 73 | 74 | Replies with a message to the selected `channel`. 75 | 76 | #### `out Parameters` 77 | 78 | * `reply`: *Required/optional*. A **string** or **file path** to be used as reply. Supports [template format](#template). *Must be defined if `reply_attachments` is not defined.* 79 | 80 | * `reply_attachments`: *Required/optional*. A **string** or **JSON file path** to be used as reply in [slack attachment format](https://api.slack.com/docs/message-attachments). You can use [messages builder](https://api.slack.com/docs/messages/builder) Supports [template format](#template). *Must be defined if `reply` is not defined.* 81 | 82 | * `reply_thread`: *optional*, *default `False`*. If enabled will post reply to original message as a thread. 83 | 84 | * `path`: *required*. The path of the resource name. This is used to get context from original message. 85 | 86 | ### Template 87 | 88 | The template uses python [Jinja2](http://jinja.pocoo.org/docs/2.9/) engine. 89 | 90 | * `variables`: You can access variables using curly braces `{{ VARIABLE_NAME }}`. 91 | All environmental variables are accessible through `{{ ENV['PATH'] }}`. 92 | Concourse exposes some metadata info like job_name, build, for more info [check concourse website](https://concourse.ci/implementing-resources.html#resource-metadata). 93 | The regex groups are accessible to the template engine as `{{ regex }}` if you used *subgroups* in your expression you can access each group with index `{{ regex[0] }}`. If you used *named subgroups* you can access them as dictionary `{{ regex['name'] }}`. 94 | The original user who initiated the trigger is accessible as variable `{{ user }}`. You can also add `@{{ user }}` to mention the user. 95 | 96 | * `white spaces`: You can use `\n` for new lines `\t` for tabs in your template. 97 | 98 | * `filters`: You can use Jinja2 [builtin filters](http://jinja.pocoo.org/docs/2.9/templates/#builtin-filters). 99 | 100 | ## Tips 101 | 102 | * Test your regex using something like [regexr.com](http://regexr.com/), it is better to use [named subgroups](http://www.regular-expressions.info/brackets.html). 103 | 104 | * Note that you need to escape the backslash with another backslash in yaml, eg.: `\s+` should be `\\s+` 105 | 106 | * Use `\s+` between commands to give a little bit of room for user send an extra space in the message. 107 | 108 | * You probably want to define **version: every** and **trigger: true** so the resource will go through all the messages and trigger the jobs. 109 | 110 | ```yaml 111 | - get: bender 112 | version: every 113 | trigger: true 114 | ``` 115 | 116 | * By default concourse resource are checked every 1m. If you want to setup multi resources you will exhaust your Slack API limits fast. You can configure slack to do an API call to your concourse using [slack outgoing webhooks](https://api.slack.com/custom-integrations/outgoing-webhooks), [concourse webhook_token](https://concourse.ci/single-page.html#webhook_token) and increase [check_every](https://concourse.ci/single-page.html#check_every) to higher eg. 1h. You can also use different [Slash commands](https://api.slack.com/slash-commands) instead of web hooks. 117 | 118 | ## TODO 119 | 120 | * Increase code coverage 121 | * Restrict per user_group 122 | * lock example 123 | 124 | ## Contribution 125 | 126 | * [aleerizw](https://github.com/aleerizw) 127 | * [rnurgaliyev](https://github.com/rnurgaliyev) 128 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=CVS 13 | 14 | # Pickle collected data for later comparisons. 15 | persistent=yes 16 | 17 | # List of plugins (as comma separated values of python modules names) to load, 18 | # usually to register additional checkers. 19 | load-plugins= 20 | 21 | # Use multiple processes to speed up Pylint. 22 | jobs=1 23 | 24 | # Allow loading of arbitrary C extensions. Extensions are imported into the 25 | # active Python interpreter and may run arbitrary code. 26 | unsafe-load-any-extension=no 27 | 28 | # A comma-separated list of package or module names from where C extensions may 29 | # be loaded. Extensions are loading into the active Python interpreter and may 30 | # run arbitrary code 31 | extension-pkg-whitelist= 32 | 33 | # Allow optimization of some AST trees. This will activate a peephole AST 34 | # optimizer, which will apply various small optimizations. For instance, it can 35 | # be used to obtain the result of joining multiple strings with the addition 36 | # operator. Joining a lot of strings can lead to a maximum recursion error in 37 | # Pylint and this flag can prevent that. It has one side effect, the resulting 38 | # AST will be different than the one from reality. 39 | optimize-ast=no 40 | 41 | 42 | [MESSAGES CONTROL] 43 | 44 | # Only show warnings with the listed confidence levels. Leave empty to show 45 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 46 | confidence= 47 | 48 | # Enable the message, report, category or checker with the given id(s). You can 49 | # either give multiple identifier separated by comma (,) or put this option 50 | # multiple time (only on the command line, not in the configuration file where 51 | # it should appear only once). See also the "--disable" option for examples. 52 | #enable= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once).You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use"--disable=all --enable=classes 62 | # --disable=W" 63 | disable=import-star-module-level,old-octal-literal,oct-method,print-statement,unpacking-in-except,parameter-unpacking,backtick,old-raise-syntax,old-ne-operator,long-suffix,dict-view-method,dict-iter-method,metaclass-assignment,next-method-called,raising-string,indexing-exception,raw_input-builtin,long-builtin,file-builtin,execfile-builtin,coerce-builtin,cmp-builtin,buffer-builtin,basestring-builtin,apply-builtin,filter-builtin-not-iterating,using-cmp-argument,useless-suppression,range-builtin-not-iterating,suppressed-message,no-absolute-import,old-division,cmp-method,reload-builtin,zip-builtin-not-iterating,intern-builtin,unichr-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,input-builtin,round-builtin,hex-method,nonzero-method,map-builtin-not-iterating 64 | 65 | 66 | [REPORTS] 67 | 68 | # Set the output format. Available formats are text, parseable, colorized, msvs 69 | # (visual studio) and html. You can also give a reporter class, eg 70 | # mypackage.mymodule.MyReporterClass. 71 | output-format=text 72 | 73 | # Put messages in a separate file for each module / package specified on the 74 | # command line instead of printing them on stdout. Reports (if any) will be 75 | # written in a file name "pylint_global.[txt|html]". 76 | files-output=no 77 | 78 | # Tells whether to display a full report or only the messages 79 | reports=yes 80 | 81 | # Python expression which should return a note less than 10 (10 is the highest 82 | # note). You have access to the variables errors warning, statement which 83 | # respectively contain the number of errors / warnings messages and the total 84 | # number of statements analyzed. This is used by the global evaluation report 85 | # (RP0004). 86 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 87 | 88 | # Template used to display messages. This is a python new-style format string 89 | # used to format the message information. See doc for all details 90 | #msg-template= 91 | 92 | 93 | [BASIC] 94 | 95 | # List of builtins function names that should not be used, separated by a comma 96 | bad-functions=map,filter,input 97 | 98 | # Good variable names which should always be accepted, separated by a comma 99 | good-names=i,j,k,ex,Run,_ 100 | 101 | # Bad variable names which should always be refused, separated by a comma 102 | bad-names=foo,bar,baz,toto,tutu,tata 103 | 104 | # Colon-delimited sets of names that determine each other's naming style when 105 | # the name regexes allow several styles. 106 | name-group= 107 | 108 | # Include a hint for the correct naming format with invalid-name 109 | include-naming-hint=no 110 | 111 | # Regular expression matching correct function names 112 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 113 | 114 | # Naming hint for function names 115 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 116 | 117 | # Regular expression matching correct variable names 118 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 119 | 120 | # Naming hint for variable names 121 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 122 | 123 | # Regular expression matching correct constant names 124 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 125 | 126 | # Naming hint for constant names 127 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 128 | 129 | # Regular expression matching correct attribute names 130 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 131 | 132 | # Naming hint for attribute names 133 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 134 | 135 | # Regular expression matching correct argument names 136 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 137 | 138 | # Naming hint for argument names 139 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 140 | 141 | # Regular expression matching correct class attribute names 142 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 143 | 144 | # Naming hint for class attribute names 145 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 146 | 147 | # Regular expression matching correct inline iteration names 148 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 149 | 150 | # Naming hint for inline iteration names 151 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 152 | 153 | # Regular expression matching correct class names 154 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 155 | 156 | # Naming hint for class names 157 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 158 | 159 | # Regular expression matching correct module names 160 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 161 | 162 | # Naming hint for module names 163 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 164 | 165 | # Regular expression matching correct method names 166 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 167 | 168 | # Naming hint for method names 169 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 170 | 171 | # Regular expression which should only match function or class names that do 172 | # not require a docstring. 173 | no-docstring-rgx=^_ 174 | 175 | # Minimum line length for functions/classes that require docstrings, shorter 176 | # ones are exempt. 177 | docstring-min-length=-1 178 | 179 | 180 | [ELIF] 181 | 182 | # Maximum number of nested blocks for function / method body 183 | max-nested-blocks=5 184 | 185 | 186 | [FORMAT] 187 | 188 | # Maximum number of characters on a single line. 189 | max-line-length=140 190 | 191 | # Regexp for a line that is allowed to be longer than the limit. 192 | ignore-long-lines=^\s*(# )??$ 193 | 194 | # Allow the body of an if to be on the same line as the test if there is no 195 | # else. 196 | single-line-if-stmt=no 197 | 198 | # List of optional constructs for which whitespace checking is disabled. `dict- 199 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 200 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 201 | # `empty-line` allows space-only lines. 202 | no-space-check=trailing-comma,dict-separator 203 | 204 | # Maximum number of lines in a module 205 | max-module-lines=1000 206 | 207 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 208 | # tab). 209 | indent-string=' ' 210 | 211 | # Number of spaces of indent required inside a hanging or continued line. 212 | indent-after-paren=4 213 | 214 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 215 | expected-line-ending-format= 216 | 217 | 218 | [LOGGING] 219 | 220 | # Logging modules to check that the string format arguments are in logging 221 | # function parameter format 222 | logging-modules=logging 223 | 224 | 225 | [MISCELLANEOUS] 226 | 227 | # List of note tags to take in consideration, separated by a comma. 228 | notes=FIXME,XXX,TODO 229 | 230 | 231 | [SIMILARITIES] 232 | 233 | # Minimum lines number of a similarity. 234 | min-similarity-lines=4 235 | 236 | # Ignore comments when computing similarities. 237 | ignore-comments=yes 238 | 239 | # Ignore docstrings when computing similarities. 240 | ignore-docstrings=yes 241 | 242 | # Ignore imports when computing similarities. 243 | ignore-imports=no 244 | 245 | 246 | [SPELLING] 247 | 248 | # Spelling dictionary name. Available dictionaries: none. To make it working 249 | # install python-enchant package. 250 | spelling-dict= 251 | 252 | # List of comma separated words that should not be checked. 253 | spelling-ignore-words= 254 | 255 | # A path to a file that contains private dictionary; one word per line. 256 | spelling-private-dict-file= 257 | 258 | # Tells whether to store unknown words to indicated private dictionary in 259 | # --spelling-private-dict-file option instead of raising a message. 260 | spelling-store-unknown-words=no 261 | 262 | 263 | [TYPECHECK] 264 | 265 | # Tells whether missing members accessed in mixin class should be ignored. A 266 | # mixin class is detected if its name ends with "mixin" (case insensitive). 267 | ignore-mixin-members=yes 268 | 269 | # List of module names for which member attributes should not be checked 270 | # (useful for modules/projects where namespaces are manipulated during runtime 271 | # and thus existing member attributes cannot be deduced by static analysis. It 272 | # supports qualified module names, as well as Unix pattern matching. 273 | ignored-modules= 274 | 275 | # List of classes names for which member attributes should not be checked 276 | # (useful for classes with attributes dynamically set). This supports can work 277 | # with qualified names. 278 | ignored-classes= 279 | 280 | # List of members which are set dynamically and missed by pylint inference 281 | # system, and so shouldn't trigger E1101 when accessed. Python regular 282 | # expressions are accepted. 283 | generated-members= 284 | 285 | 286 | [VARIABLES] 287 | 288 | # Tells whether we should check for unused import in __init__ files. 289 | init-import=no 290 | 291 | # A regular expression matching the name of dummy variables (i.e. expectedly 292 | # not used). 293 | dummy-variables-rgx=_$|dummy 294 | 295 | # List of additional names supposed to be defined in builtins. Remember that 296 | # you should avoid to define new builtins when possible. 297 | additional-builtins= 298 | 299 | # List of strings which can identify a callback function by name. A callback 300 | # name must start or end with one of those strings. 301 | callbacks=cb_,_cb 302 | 303 | 304 | [CLASSES] 305 | 306 | # List of method names used to declare (i.e. assign) instance attributes. 307 | defining-attr-methods=__init__,__new__,setUp 308 | 309 | # List of valid names for the first argument in a class method. 310 | valid-classmethod-first-arg=cls 311 | 312 | # List of valid names for the first argument in a metaclass class method. 313 | valid-metaclass-classmethod-first-arg=mcs 314 | 315 | # List of member names, which should be excluded from the protected access 316 | # warning. 317 | exclude-protected=_asdict,_fields,_replace,_source,_make 318 | 319 | 320 | [DESIGN] 321 | 322 | # Maximum number of arguments for function / method 323 | max-args=5 324 | 325 | # Argument names that match this expression will be ignored. Default to name 326 | # with leading underscore 327 | ignored-argument-names=_.* 328 | 329 | # Maximum number of locals for function / method body 330 | max-locals=15 331 | 332 | # Maximum number of return / yield for function / method body 333 | max-returns=6 334 | 335 | # Maximum number of branch for function / method body 336 | max-branches=12 337 | 338 | # Maximum number of statements in function / method body 339 | max-statements=50 340 | 341 | # Maximum number of parents for a class (see R0901). 342 | max-parents=7 343 | 344 | # Maximum number of attributes for a class (see R0902). 345 | max-attributes=7 346 | 347 | # Minimum number of public methods for a class (see R0903). 348 | min-public-methods=2 349 | 350 | # Maximum number of public methods for a class (see R0904). 351 | max-public-methods=20 352 | 353 | # Maximum number of boolean expressions in a if statement 354 | max-bool-expr=5 355 | 356 | 357 | [IMPORTS] 358 | 359 | # Deprecated modules which should not be used, separated by a comma 360 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 361 | 362 | # Create a graph of every (i.e. internal and external) dependencies in the 363 | # given file (report RP0402 must not be disabled) 364 | import-graph= 365 | 366 | # Create a graph of external dependencies in the given file (report RP0402 must 367 | # not be disabled) 368 | ext-import-graph= 369 | 370 | # Create a graph of internal dependencies in the given file (report RP0402 must 371 | # not be disabled) 372 | int-import-graph= 373 | 374 | 375 | [EXCEPTIONS] 376 | 377 | # Exceptions that will emit a warning when being caught. Defaults to 378 | # "Exception" 379 | overgeneral-exceptions=Exception 380 | --------------------------------------------------------------------------------