diff --git a/tools/custom_check.py b/tools/custom_check.py index 54c4ae9..ce89004 100644 --- a/tools/custom_check.py +++ b/tools/custom_check.py @@ -8,89 +8,97 @@ if MYPY: whitespace_rules = [ # This linter should be first since bash_rules depends on it. - {'pattern': r'\s+$', - 'strip': '\n', - 'description': 'Fix trailing whitespace'}, - {'pattern': '\t', - 'strip': '\n', - 'description': 'Fix tab-based whitespace'}, + {'pattern': r'\s+$', 'strip': '\n', 'description': 'Fix trailing whitespace'}, + {'pattern': '\t', 'strip': '\n', 'description': 'Fix tab-based whitespace'}, ] # type: List[Rule] -markdown_whitespace_rules = list([rule for rule in whitespace_rules if rule['pattern'] != r'\s+$']) + [ +markdown_whitespace_rules = list( + [rule for rule in whitespace_rules if rule['pattern'] != r'\s+$'] +) + [ # Two spaces trailing a line with other content is okay--it's a markdown line break. # This rule finds one space trailing a non-space, three or more trailing spaces, and # spaces on an empty line. - {'pattern': r'((?~"]', - 'description': 'Missing whitespace after "="'}, - {'pattern': r'":\w[^"]*$', - 'description': 'Missing whitespace after ":"'}, - {'pattern': r"':\w[^']*$", - 'description': 'Missing whitespace after ":"'}, - {'pattern': r"^\s+[#]\w", - 'strip': '\n', - 'description': 'Missing whitespace after "#"'}, - {'pattern': r"assertEquals[(]", - 'description': 'Use assertEqual, not assertEquals (which is deprecated).'}, - {'pattern': r'self: Any', - 'description': 'you can omit Any annotation for self', - 'good_lines': ['def foo (self):'], - 'bad_lines': ['def foo(self: Any):']}, - {'pattern': r"== None", - 'description': 'Use `is None` to check whether something is None'}, - {'pattern': r"type:[(]", - 'description': 'Missing whitespace after ":" in type annotation'}, - {'pattern': r"# type [(]", - 'description': 'Missing : after type in type annotation'}, - {'pattern': r"#type", - 'description': 'Missing whitespace after "#" in type annotation'}, - {'pattern': r'if[(]', - 'description': 'Missing space between if and ('}, - {'pattern': r", [)]", - 'description': 'Unnecessary whitespace between "," and ")"'}, - {'pattern': r"% [(]", - 'description': 'Unnecessary whitespace between "%" and "("'}, + {'pattern': r" =" + r'[^ =>~"]', 'description': 'Missing whitespace after "="'}, + {'pattern': r'":\w[^"]*$', 'description': 'Missing whitespace after ":"'}, + {'pattern': r"':\w[^']*$", 'description': 'Missing whitespace after ":"'}, + {'pattern': r"^\s+[#]\w", 'strip': '\n', 'description': 'Missing whitespace after "#"'}, + { + 'pattern': r"assertEquals[(]", + 'description': 'Use assertEqual, not assertEquals (which is deprecated).', + }, + { + 'pattern': r'self: Any', + 'description': 'you can omit Any annotation for self', + 'good_lines': ['def foo (self):'], + 'bad_lines': ['def foo(self: Any):'], + }, + {'pattern': r"== None", 'description': 'Use `is None` to check whether something is None'}, + {'pattern': r"type:[(]", 'description': 'Missing whitespace after ":" in type annotation'}, + {'pattern': r"# type [(]", 'description': 'Missing : after type in type annotation'}, + {'pattern': r"#type", 'description': 'Missing whitespace after "#" in type annotation'}, + {'pattern': r'if[(]', 'description': 'Missing space between if and ('}, + {'pattern': r", [)]", 'description': 'Unnecessary whitespace between "," and ")"'}, + {'pattern': r"% [(]", 'description': 'Unnecessary whitespace between "%" and "("'}, # This next check could have false positives, but it seems pretty # rare; if we find any, they can be added to the exclude list for # this rule. - {'pattern': r' % [a-zA-Z0-9_.]*\)?$', - 'description': 'Used % comprehension without a tuple'}, - {'pattern': r'.*%s.* % \([a-zA-Z0-9_.]*\)$', - 'description': 'Used % comprehension without a tuple'}, - {'pattern': r'__future__', - 'include_only': {'zulip_bots/zulip_bots/bots/'}, - 'description': 'Bots no longer need __future__ imports.'}, - {'pattern': r'#!/usr/bin/env python$', - 'include_only': {'zulip_bots/'}, - 'description': 'Python shebangs must be python3'}, - {'pattern': r'(^|\s)open\s*\(', - 'description': 'open() should not be used in Zulip\'s bots. Use functions' - ' provided by the bots framework to access the filesystem.', - 'include_only': {'zulip_bots/zulip_bots/bots/'}}, - {'pattern': r'pprint', - 'description': 'Used pprint, which is most likely a debugging leftover. For user output, use print().'}, - {'pattern': r'\(BotTestCase\)', - 'bad_lines': ['class TestSomeBot(BotTestCase):'], - 'description': 'Bot test cases should directly inherit from BotTestCase *and* DefaultTests.'}, - {'pattern': r'\(DefaultTests, BotTestCase\)', - 'bad_lines': ['class TestSomeBot(DefaultTests, BotTestCase):'], - 'good_lines': ['class TestSomeBot(BotTestCase, DefaultTests):'], - 'description': 'Bot test cases should inherit from BotTestCase before DefaultTests.'}, + { + 'pattern': r' % [a-zA-Z0-9_.]*\)?$', + 'description': 'Used % comprehension without a tuple', + }, + { + 'pattern': r'.*%s.* % \([a-zA-Z0-9_.]*\)$', + 'description': 'Used % comprehension without a tuple', + }, + { + 'pattern': r'__future__', + 'include_only': {'zulip_bots/zulip_bots/bots/'}, + 'description': 'Bots no longer need __future__ imports.', + }, + { + 'pattern': r'#!/usr/bin/env python$', + 'include_only': {'zulip_bots/'}, + 'description': 'Python shebangs must be python3', + }, + { + 'pattern': r'(^|\s)open\s*\(', + 'description': 'open() should not be used in Zulip\'s bots. Use functions' + ' provided by the bots framework to access the filesystem.', + 'include_only': {'zulip_bots/zulip_bots/bots/'}, + }, + { + 'pattern': r'pprint', + 'description': 'Used pprint, which is most likely a debugging leftover. For user output, use print().', + }, + { + 'pattern': r'\(BotTestCase\)', + 'bad_lines': ['class TestSomeBot(BotTestCase):'], + 'description': 'Bot test cases should directly inherit from BotTestCase *and* DefaultTests.', + }, + { + 'pattern': r'\(DefaultTests, BotTestCase\)', + 'bad_lines': ['class TestSomeBot(DefaultTests, BotTestCase):'], + 'good_lines': ['class TestSomeBot(BotTestCase, DefaultTests):'], + 'description': 'Bot test cases should inherit from BotTestCase before DefaultTests.', + }, *whitespace_rules, ], max_length=140, @@ -99,9 +107,11 @@ python_rules = RuleList( bash_rules = RuleList( langs=['sh'], rules=[ - {'pattern': r'#!.*sh [-xe]', - 'description': 'Fix shebang line with proper call to /usr/bin/env for Bash path, change -x|-e switches' - ' to set -x|set -e'}, + { + 'pattern': r'#!.*sh [-xe]', + 'description': 'Fix shebang line with proper call to /usr/bin/env for Bash path, change -x|-e switches' + ' to set -x|set -e', + }, *whitespace_rules[0:1], ], ) @@ -116,20 +126,27 @@ json_rules = RuleList( # version of the tab-based whitespace rule (we can't just use # exclude in whitespace_rules, since we only want to ignore # JSON files with tab-based whitespace, not webhook code). - rules= whitespace_rules[0:1], + rules=whitespace_rules[0:1], ) prose_style_rules = [ - {'pattern': r'[^\/\#\-"]([jJ]avascript)', # exclude usage in hrefs/divs - 'description': "javascript should be spelled JavaScript"}, - {'pattern': r'''[^\/\-\."'\_\=\>]([gG]ithub)[^\.\-\_"\<]''', # exclude usage in hrefs/divs - 'description': "github should be spelled GitHub"}, - {'pattern': r'[oO]rganisation', # exclude usage in hrefs/divs - 'description': "Organization is spelled with a z"}, - {'pattern': r'!!! warning', - 'description': "!!! warning is invalid; it's spelled '!!! warn'"}, - {'pattern': r'[^-_]botserver(?!rc)|bot server', - 'description': "Use Botserver instead of botserver or Botserver."}, + { + 'pattern': r'[^\/\#\-"]([jJ]avascript)', # exclude usage in hrefs/divs + 'description': "javascript should be spelled JavaScript", + }, + { + 'pattern': r'''[^\/\-\."'\_\=\>]([gG]ithub)[^\.\-\_"\<]''', # exclude usage in hrefs/divs + 'description': "github should be spelled GitHub", + }, + { + 'pattern': r'[oO]rganisation', # exclude usage in hrefs/divs + 'description': "Organization is spelled with a z", + }, + {'pattern': r'!!! warning', 'description': "!!! warning is invalid; it's spelled '!!! warn'"}, + { + 'pattern': r'[^-_]botserver(?!rc)|bot server', + 'description': "Use Botserver instead of botserver or Botserver.", + }, ] # type: List[Rule] markdown_docs_length_exclude = { @@ -141,8 +158,10 @@ markdown_rules = RuleList( rules=[ *markdown_whitespace_rules, *prose_style_rules, - {'pattern': r'\[(?P[^\]]+)\]\((?P=url)\)', - 'description': 'Linkified markdown URLs should use cleaner syntax.'} + { + 'pattern': r'\[(?P[^\]]+)\]\((?P=url)\)', + 'description': 'Linkified markdown URLs should use cleaner syntax.', + }, ], max_length=120, length_exclude=markdown_docs_length_exclude, diff --git a/tools/deploy b/tools/deploy index 73bea63..03d2eb1 100755 --- a/tools/deploy +++ b/tools/deploy @@ -18,6 +18,7 @@ bold = '\033[1m' # type: str bots_dir = '.bots' # type: str + def pack(options: argparse.Namespace) -> None: # Basic sanity checks for input. if not options.path: @@ -53,15 +54,20 @@ def pack(options: argparse.Namespace) -> None: # Pack the zuliprc zip_file.write(options.config, 'zuliprc') # Pack the config file for the botfarm. - bot_config = textwrap.dedent('''\ + bot_config = textwrap.dedent( + '''\ [deploy] bot={} zuliprc=zuliprc - '''.format(options.main)) + '''.format( + options.main + ) + ) zip_file.writestr('config.ini', bot_config) zip_file.close() print('pack: Created zip file at: {}.'.format(zip_file_path)) + def check_common_options(options: argparse.Namespace) -> None: if not options.server: print('tools/deploy: URL to Botfarm server not specified.') @@ -70,18 +76,20 @@ def check_common_options(options: argparse.Namespace) -> None: print('tools/deploy: Botfarm deploy token not specified.') sys.exit(1) -def handle_common_response_without_data(response: Response, - operation: str, - success_message: str) -> bool: + +def handle_common_response_without_data( + response: Response, operation: str, success_message: str +) -> bool: return handle_common_response( response=response, operation=operation, - success_handler=lambda r: print('{}: {}'.format(operation, success_message)) + success_handler=lambda r: print('{}: {}'.format(operation, success_message)), ) -def handle_common_response(response: Response, - operation: str, - success_handler: Callable[[Dict[str, Any]], Any]) -> bool: + +def handle_common_response( + response: Response, operation: str, success_handler: Callable[[Dict[str, Any]], Any] +) -> bool: if response.status_code == requests.codes.ok: response_data = response.json() if response_data['status'] == 'success': @@ -99,6 +107,7 @@ def handle_common_response(response: Response, print('{}: Error {}. Aborting.'.format(operation, response.status_code)) return False + def upload(options: argparse.Namespace) -> None: check_common_options(options) file_path = os.path.join(bots_dir, options.botname + '.zip') @@ -109,10 +118,13 @@ def upload(options: argparse.Namespace) -> None: headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/upload') response = requests.post(url, files=files, headers=headers) - result = handle_common_response_without_data(response, 'upload', 'Uploaded the bot package to botfarm.') + result = handle_common_response_without_data( + response, 'upload', 'Uploaded the bot package to botfarm.' + ) if result is False: sys.exit(1) + def clean(options: argparse.Namespace) -> None: file_path = os.path.join(bots_dir, options.botname + '.zip') if os.path.exists(file_path): @@ -121,42 +133,53 @@ def clean(options: argparse.Namespace) -> None: else: print('clean: File \'{}\' not found.'.format(file_path)) + def process(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/process') payload = {'name': options.botname} response = requests.post(url, headers=headers, json=payload) - result = handle_common_response_without_data(response, 'process', 'The bot has been processed by the botfarm.') + result = handle_common_response_without_data( + response, 'process', 'The bot has been processed by the botfarm.' + ) if result is False: sys.exit(1) + def start(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/start') payload = {'name': options.botname} response = requests.post(url, headers=headers, json=payload) - result = handle_common_response_without_data(response, 'start', 'The bot has been started by the botfarm.') + result = handle_common_response_without_data( + response, 'start', 'The bot has been started by the botfarm.' + ) if result is False: sys.exit(1) + def stop(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/stop') payload = {'name': options.botname} response = requests.post(url, headers=headers, json=payload) - result = handle_common_response_without_data(response, 'stop', 'The bot has been stopped by the botfarm.') + result = handle_common_response_without_data( + response, 'stop', 'The bot has been stopped by the botfarm.' + ) if result is False: sys.exit(1) + def prepare(options: argparse.Namespace) -> None: pack(options) upload(options) clean(options) process(options) + def log(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} @@ -171,16 +194,20 @@ def log(options: argparse.Namespace) -> None: if result is False: sys.exit(1) + def delete(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/delete') payload = {'name': options.botname} response = requests.post(url, headers=headers, json=payload) - result = handle_common_response_without_data(response, 'delete', 'The bot has been removed from the botfarm.') + result = handle_common_response_without_data( + response, 'delete', 'The bot has been removed from the botfarm.' + ) if result is False: sys.exit(1) + def list_bots(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} @@ -190,10 +217,13 @@ def list_bots(options: argparse.Namespace) -> None: pretty_print = False url = urllib.parse.urljoin(options.server, 'bots/list') response = requests.get(url, headers=headers) - result = handle_common_response(response, 'ls', lambda r: print_bots(r['bots']['list'], pretty_print)) + result = handle_common_response( + response, 'ls', lambda r: print_bots(r['bots']['list'], pretty_print) + ) if result is False: sys.exit(1) + def print_bots(bots: List[Any], pretty_print: bool) -> None: if pretty_print: print_bots_pretty(bots) @@ -201,6 +231,7 @@ def print_bots(bots: List[Any], pretty_print: bool) -> None: for bot in bots: print('{}\t{}\t{}\t{}'.format(bot['name'], bot['status'], bot['email'], bot['site'])) + def print_bots_pretty(bots: List[Any]) -> None: if len(bots) == 0: print('ls: No bots found on the botfarm') @@ -231,6 +262,7 @@ def print_bots_pretty(bots: List[Any]) -> None: ) print(row) + def main() -> None: usage = """tools/deploy [options] @@ -267,23 +299,25 @@ To list user's bots, use: parser = argparse.ArgumentParser(usage=usage) parser.add_argument('command', help='Command to run.') parser.add_argument('botname', nargs='?', help='Name of bot to operate on.') - parser.add_argument('--server', '-s', - metavar='SERVERURL', - default=os.environ.get('SERVER', ''), - help='Url of the Zulip Botfarm server.') - parser.add_argument('--token', '-t', - default=os.environ.get('TOKEN', ''), - help='Deploy Token for the Botfarm.') - parser.add_argument('--path', '-p', - help='Path to the bot directory.') - parser.add_argument('--config', '-c', - help='Path to the zuliprc file.') - parser.add_argument('--main', '-m', - help='Path to the bot\'s main file, relative to the bot\'s directory.') - parser.add_argument('--lines', '-l', - help='Number of lines in log required.') - parser.add_argument('--format', '-f', action='store_true', - help='Print user\'s bots in human readable format') + parser.add_argument( + '--server', + '-s', + metavar='SERVERURL', + default=os.environ.get('SERVER', ''), + help='Url of the Zulip Botfarm server.', + ) + parser.add_argument( + '--token', '-t', default=os.environ.get('TOKEN', ''), help='Deploy Token for the Botfarm.' + ) + parser.add_argument('--path', '-p', help='Path to the bot directory.') + parser.add_argument('--config', '-c', help='Path to the zuliprc file.') + parser.add_argument( + '--main', '-m', help='Path to the bot\'s main file, relative to the bot\'s directory.' + ) + parser.add_argument('--lines', '-l', help='Number of lines in log required.') + parser.add_argument( + '--format', '-f', action='store_true', help='Print user\'s bots in human readable format' + ) options = parser.parse_args() if not options.command: print('tools/deploy: No command specified.') @@ -308,5 +342,7 @@ To list user's bots, use: commands[options.command](options) else: print('tools/deploy: No command \'{}\' found.'.format(options.command)) + + if __name__ == '__main__': main() diff --git a/tools/gitlint-rules.py b/tools/gitlint-rules.py index 322f96f..2b53054 100644 --- a/tools/gitlint-rules.py +++ b/tools/gitlint-rules.py @@ -11,84 +11,253 @@ from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation # License: MIT # Ref: fit_commit/validators/tense.rb WORD_SET = { - 'adds', 'adding', 'added', - 'allows', 'allowing', 'allowed', - 'amends', 'amending', 'amended', - 'bumps', 'bumping', 'bumped', - 'calculates', 'calculating', 'calculated', - 'changes', 'changing', 'changed', - 'cleans', 'cleaning', 'cleaned', - 'commits', 'committing', 'committed', - 'corrects', 'correcting', 'corrected', - 'creates', 'creating', 'created', - 'darkens', 'darkening', 'darkened', - 'disables', 'disabling', 'disabled', - 'displays', 'displaying', 'displayed', - 'documents', 'documenting', 'documented', - 'drys', 'drying', 'dryed', - 'ends', 'ending', 'ended', - 'enforces', 'enforcing', 'enforced', - 'enqueues', 'enqueuing', 'enqueued', - 'extracts', 'extracting', 'extracted', - 'finishes', 'finishing', 'finished', - 'fixes', 'fixing', 'fixed', - 'formats', 'formatting', 'formatted', - 'guards', 'guarding', 'guarded', - 'handles', 'handling', 'handled', - 'hides', 'hiding', 'hid', - 'increases', 'increasing', 'increased', - 'ignores', 'ignoring', 'ignored', - 'implements', 'implementing', 'implemented', - 'improves', 'improving', 'improved', - 'keeps', 'keeping', 'kept', - 'kills', 'killing', 'killed', - 'makes', 'making', 'made', - 'merges', 'merging', 'merged', - 'moves', 'moving', 'moved', - 'permits', 'permitting', 'permitted', - 'prevents', 'preventing', 'prevented', - 'pushes', 'pushing', 'pushed', - 'rebases', 'rebasing', 'rebased', - 'refactors', 'refactoring', 'refactored', - 'removes', 'removing', 'removed', - 'renames', 'renaming', 'renamed', - 'reorders', 'reordering', 'reordered', - 'replaces', 'replacing', 'replaced', - 'requires', 'requiring', 'required', - 'restores', 'restoring', 'restored', - 'sends', 'sending', 'sent', - 'sets', 'setting', - 'separates', 'separating', 'separated', - 'shows', 'showing', 'showed', - 'simplifies', 'simplifying', 'simplified', - 'skips', 'skipping', 'skipped', - 'sorts', 'sorting', - 'speeds', 'speeding', 'sped', - 'starts', 'starting', 'started', - 'supports', 'supporting', 'supported', - 'takes', 'taking', 'took', - 'testing', 'tested', # 'tests' excluded to reduce false negative - 'truncates', 'truncating', 'truncated', - 'updates', 'updating', 'updated', - 'uses', 'using', 'used', + 'adds', + 'adding', + 'added', + 'allows', + 'allowing', + 'allowed', + 'amends', + 'amending', + 'amended', + 'bumps', + 'bumping', + 'bumped', + 'calculates', + 'calculating', + 'calculated', + 'changes', + 'changing', + 'changed', + 'cleans', + 'cleaning', + 'cleaned', + 'commits', + 'committing', + 'committed', + 'corrects', + 'correcting', + 'corrected', + 'creates', + 'creating', + 'created', + 'darkens', + 'darkening', + 'darkened', + 'disables', + 'disabling', + 'disabled', + 'displays', + 'displaying', + 'displayed', + 'documents', + 'documenting', + 'documented', + 'drys', + 'drying', + 'dryed', + 'ends', + 'ending', + 'ended', + 'enforces', + 'enforcing', + 'enforced', + 'enqueues', + 'enqueuing', + 'enqueued', + 'extracts', + 'extracting', + 'extracted', + 'finishes', + 'finishing', + 'finished', + 'fixes', + 'fixing', + 'fixed', + 'formats', + 'formatting', + 'formatted', + 'guards', + 'guarding', + 'guarded', + 'handles', + 'handling', + 'handled', + 'hides', + 'hiding', + 'hid', + 'increases', + 'increasing', + 'increased', + 'ignores', + 'ignoring', + 'ignored', + 'implements', + 'implementing', + 'implemented', + 'improves', + 'improving', + 'improved', + 'keeps', + 'keeping', + 'kept', + 'kills', + 'killing', + 'killed', + 'makes', + 'making', + 'made', + 'merges', + 'merging', + 'merged', + 'moves', + 'moving', + 'moved', + 'permits', + 'permitting', + 'permitted', + 'prevents', + 'preventing', + 'prevented', + 'pushes', + 'pushing', + 'pushed', + 'rebases', + 'rebasing', + 'rebased', + 'refactors', + 'refactoring', + 'refactored', + 'removes', + 'removing', + 'removed', + 'renames', + 'renaming', + 'renamed', + 'reorders', + 'reordering', + 'reordered', + 'replaces', + 'replacing', + 'replaced', + 'requires', + 'requiring', + 'required', + 'restores', + 'restoring', + 'restored', + 'sends', + 'sending', + 'sent', + 'sets', + 'setting', + 'separates', + 'separating', + 'separated', + 'shows', + 'showing', + 'showed', + 'simplifies', + 'simplifying', + 'simplified', + 'skips', + 'skipping', + 'skipped', + 'sorts', + 'sorting', + 'speeds', + 'speeding', + 'sped', + 'starts', + 'starting', + 'started', + 'supports', + 'supporting', + 'supported', + 'takes', + 'taking', + 'took', + 'testing', + 'tested', # 'tests' excluded to reduce false negative + 'truncates', + 'truncating', + 'truncated', + 'updates', + 'updating', + 'updated', + 'uses', + 'using', + 'used', } imperative_forms = [ - 'add', 'allow', 'amend', 'bump', 'calculate', 'change', 'clean', 'commit', - 'correct', 'create', 'darken', 'disable', 'display', 'document', 'dry', - 'end', 'enforce', 'enqueue', 'extract', 'finish', 'fix', 'format', 'guard', - 'handle', 'hide', 'ignore', 'implement', 'improve', 'increase', 'keep', - 'kill', 'make', 'merge', 'move', 'permit', 'prevent', 'push', 'rebase', - 'refactor', 'remove', 'rename', 'reorder', 'replace', 'require', 'restore', - 'send', 'separate', 'set', 'show', 'simplify', 'skip', 'sort', 'speed', - 'start', 'support', 'take', 'test', 'truncate', 'update', 'use', + 'add', + 'allow', + 'amend', + 'bump', + 'calculate', + 'change', + 'clean', + 'commit', + 'correct', + 'create', + 'darken', + 'disable', + 'display', + 'document', + 'dry', + 'end', + 'enforce', + 'enqueue', + 'extract', + 'finish', + 'fix', + 'format', + 'guard', + 'handle', + 'hide', + 'ignore', + 'implement', + 'improve', + 'increase', + 'keep', + 'kill', + 'make', + 'merge', + 'move', + 'permit', + 'prevent', + 'push', + 'rebase', + 'refactor', + 'remove', + 'rename', + 'reorder', + 'replace', + 'require', + 'restore', + 'send', + 'separate', + 'set', + 'show', + 'simplify', + 'skip', + 'sort', + 'speed', + 'start', + 'support', + 'take', + 'test', + 'truncate', + 'update', + 'use', ] imperative_forms.sort() def head_binary_search(key: str, words: List[str]) -> str: - """ Find the imperative mood version of `word` by looking at the first - 3 characters. """ + """Find the imperative mood version of `word` by looking at the first + 3 characters.""" # Edge case: 'disable' and 'display' have the same 3 starting letters. if key in ['displays', 'displaying', 'displayed']: @@ -114,16 +283,18 @@ def head_binary_search(key: str, words: List[str]) -> str: class ImperativeMood(LineRule): - """ This rule will enforce that the commit message title uses imperative + """This rule will enforce that the commit message title uses imperative mood. This is done by checking if the first word is in `WORD_SET`, if so - show the word in the correct mood. """ + show the word in the correct mood.""" name = "title-imperative-mood" id = "Z1" target = CommitMessageTitle - error_msg = ('The first word in commit title should be in imperative mood ' - '("{word}" -> "{imperative}"): "{title}"') + error_msg = ( + 'The first word in commit title should be in imperative mood ' + '("{word}" -> "{imperative}"): "{title}"' + ) def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]: violations = [] @@ -134,11 +305,14 @@ class ImperativeMood(LineRule): if first_word in WORD_SET: imperative = head_binary_search(first_word, imperative_forms) - violation = RuleViolation(self.id, self.error_msg.format( - word=first_word, - imperative=imperative, - title=commit.message.title, - )) + violation = RuleViolation( + self.id, + self.error_msg.format( + word=first_word, + imperative=imperative, + title=commit.message.title, + ), + ) violations.append(violation) diff --git a/tools/lint b/tools/lint index 2c520b1..ca67aab 100755 --- a/tools/lint +++ b/tools/lint @@ -12,6 +12,7 @@ EXCLUDED_FILES = [ 'zulip/integrations/perforce/git_p4.py', ] + def run() -> None: parser = argparse.ArgumentParser() add_default_linter_arguments(parser) @@ -19,15 +20,23 @@ def run() -> None: linter_config = LinterConfig(args) - by_lang = linter_config.list_files(file_types=['py', 'sh', 'json', 'md', 'txt'], - exclude=EXCLUDED_FILES) + by_lang = linter_config.list_files( + file_types=['py', 'sh', 'json', 'md', 'txt'], exclude=EXCLUDED_FILES + ) - linter_config.external_linter('mypy', [sys.executable, 'tools/run-mypy'], ['py'], pass_targets=False, - description="Static type checker for Python (config: mypy.ini)") - linter_config.external_linter('flake8', ['flake8'], ['py'], - description="Standard Python linter (config: .flake8)") - linter_config.external_linter('gitlint', ['tools/lint-commits'], - description="Git Lint for commit messages") + linter_config.external_linter( + 'mypy', + [sys.executable, 'tools/run-mypy'], + ['py'], + pass_targets=False, + description="Static type checker for Python (config: mypy.ini)", + ) + linter_config.external_linter( + 'flake8', ['flake8'], ['py'], description="Standard Python linter (config: .flake8)" + ) + linter_config.external_linter( + 'gitlint', ['tools/lint-commits'], description="Git Lint for commit messages" + ) @linter_config.lint def custom_py() -> int: @@ -45,5 +54,6 @@ def run() -> None: linter_config.do_lint() + if __name__ == '__main__': run() diff --git a/tools/provision b/tools/provision index 63f2557..83bb73d 100755 --- a/tools/provision +++ b/tools/provision @@ -15,32 +15,39 @@ green = '\033[92m' end_format = '\033[0m' bold = '\033[1m' + def main(): usage = """./tools/provision Creates a Python virtualenv. Its Python version is equal to the Python version this command is executed with.""" parser = argparse.ArgumentParser(usage=usage) - parser.add_argument('--python-interpreter', '-p', - metavar='PATH_TO_PYTHON_INTERPRETER', - default=os.path.abspath(sys.executable), - help='Path to the Python interpreter to use when provisioning.') - parser.add_argument('--force', '-f', action='store_true', - help='create venv even with outdated Python version.') + parser.add_argument( + '--python-interpreter', + '-p', + metavar='PATH_TO_PYTHON_INTERPRETER', + default=os.path.abspath(sys.executable), + help='Path to the Python interpreter to use when provisioning.', + ) + parser.add_argument( + '--force', '-f', action='store_true', help='create venv even with outdated Python version.' + ) options = parser.parse_args() base_dir = os.path.abspath(os.path.join(__file__, '..', '..')) - py_version_output = subprocess.check_output([options.python_interpreter, '--version'], - stderr=subprocess.STDOUT, universal_newlines=True) + py_version_output = subprocess.check_output( + [options.python_interpreter, '--version'], stderr=subprocess.STDOUT, universal_newlines=True + ) # The output has the format "Python 1.2.3" py_version_list = py_version_output.split()[1].split('.') py_version = tuple(int(num) for num in py_version_list[0:2]) venv_name = 'zulip-api-py{}-venv'.format(py_version[0]) if py_version <= (3, 1) and (not options.force): - print(red + "Provision failed: Cannot create venv with outdated Python version ({}).\n" - "Maybe try `python3 tools/provision`." - .format(py_version_output.strip()) + end_format) + print( + red + "Provision failed: Cannot create venv with outdated Python version ({}).\n" + "Maybe try `python3 tools/provision`.".format(py_version_output.strip()) + end_format + ) sys.exit(1) venv_dir = os.path.join(base_dir, venv_name) @@ -48,18 +55,24 @@ the Python version this command is executed with.""" try: return_code = subprocess.call([options.python_interpreter, '-m', 'venv', venv_dir]) except OSError: - print("{red}Installation with venv failed. Probable errors are: " - "You are on Ubuntu and you haven't installed python3-venv," - "or you are running an unsupported python version" - "or python is not installed properly{end_format}" - .format(red=red, end_format=end_format)) + print( + "{red}Installation with venv failed. Probable errors are: " + "You are on Ubuntu and you haven't installed python3-venv," + "or you are running an unsupported python version" + "or python is not installed properly{end_format}".format( + red=red, end_format=end_format + ) + ) sys.exit(1) raise else: # subprocess.call returns 0 if a script executed successfully if return_code: - raise OSError("The command `{} -m venv {}` failed. Virtualenv not created!" - .format(options.python_interpreter, venv_dir)) + raise OSError( + "The command `{} -m venv {}` failed. Virtualenv not created!".format( + options.python_interpreter, venv_dir + ) + ) print("New virtualenv created.") else: print("Virtualenv already exists.") @@ -85,10 +98,21 @@ the Python version this command is executed with.""" pip_path = os.path.join(venv_dir, venv_exec_dir, 'pip') # We first install a modern version of pip that supports --prefix subprocess.call([pip_path, 'install', 'pip>=9.0']) - if subprocess.call([pip_path, 'install', '--prefix', venv_dir, '-r', - os.path.join(base_dir, requirements_filename)]): - raise OSError("The command `pip install -r {}` failed. Dependencies not installed!" - .format(os.path.join(base_dir, requirements_filename))) + if subprocess.call( + [ + pip_path, + 'install', + '--prefix', + venv_dir, + '-r', + os.path.join(base_dir, requirements_filename), + ] + ): + raise OSError( + "The command `pip install -r {}` failed. Dependencies not installed!".format( + os.path.join(base_dir, requirements_filename) + ) + ) install_dependencies('requirements.txt') @@ -105,10 +129,7 @@ the Python version this command is executed with.""" print(green + 'Success!' + end_format) - activate_command = os.path.join(base_dir, - venv_dir, - venv_exec_dir, - 'activate') + activate_command = os.path.join(base_dir, venv_dir, venv_exec_dir, 'activate') # We make the path look like a Unix path, because most Windows users # are likely to be running in a bash shell. activate_command = activate_command.replace(os.sep, '/') diff --git a/tools/release-packages b/tools/release-packages index d847669..85b20d3 100755 --- a/tools/release-packages +++ b/tools/release-packages @@ -13,6 +13,7 @@ import twine.commands.upload REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + @contextmanager def cd(newdir): prevdir = os.getcwd() @@ -22,6 +23,7 @@ def cd(newdir): finally: os.chdir(prevdir) + def _generate_dist(dist_type, setup_file, package_name, setup_args): message = 'Generating {dist_type} for {package_name}.'.format( dist_type=dist_type, @@ -40,13 +42,13 @@ def _generate_dist(dist_type, setup_file, package_name, setup_args): ) print(crayons.green(message, bold=True)) + def generate_bdist_wheel(setup_file, package_name, universal=False): if universal: - _generate_dist('bdist_wheel', setup_file, package_name, - ['bdist_wheel', '--universal']) + _generate_dist('bdist_wheel', setup_file, package_name, ['bdist_wheel', '--universal']) else: - _generate_dist('bdist_wheel', setup_file, package_name, - ['bdist_wheel']) + _generate_dist('bdist_wheel', setup_file, package_name, ['bdist_wheel']) + def twine_upload(dist_dirs): message = 'Uploading distributions under the following directories:' @@ -55,14 +57,12 @@ def twine_upload(dist_dirs): print(crayons.yellow(dist_dir)) twine.commands.upload.main(dist_dirs) + def cleanup(package_dir): build_dir = os.path.join(package_dir, 'build') temp_dir = os.path.join(package_dir, 'temp') dist_dir = os.path.join(package_dir, 'dist') - egg_info = os.path.join( - package_dir, - '{}.egg-info'.format(os.path.basename(package_dir)) - ) + egg_info = os.path.join(package_dir, '{}.egg-info'.format(os.path.basename(package_dir))) def _rm_if_it_exists(directory): if os.path.isdir(directory): @@ -74,6 +74,7 @@ def cleanup(package_dir): _rm_if_it_exists(dist_dir) _rm_if_it_exists(egg_info) + def set_variable(fp, variable, value): fh, temp_abs_path = tempfile.mkstemp() with os.fdopen(fh, 'w') as new_file, open(fp) as old_file: @@ -90,10 +91,10 @@ def set_variable(fp, variable, value): os.remove(fp) shutil.move(temp_abs_path, fp) - message = 'Set {variable} in {fp} to {value}.'.format( - fp=fp, variable=variable, value=value) + message = 'Set {variable} in {fp} to {value}.'.format(fp=fp, variable=variable, value=value) print(crayons.white(message, bold=True)) + def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag): common = os.path.join(zulip_repo_dir, 'requirements', 'common.in') prod = os.path.join(zulip_repo_dir, 'requirements', 'prod.txt') @@ -115,10 +116,8 @@ def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag): url_zulip = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}_git&subdirectory={name}\n' url_zulip_bots = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}+git&subdirectory={name}\n' - zulip_bots_line = url_zulip_bots.format(tag=hash_or_tag, name='zulip_bots', - version=version) - zulip_line = url_zulip.format(tag=hash_or_tag, name='zulip', - version=version) + zulip_bots_line = url_zulip_bots.format(tag=hash_or_tag, name='zulip_bots', version=version) + zulip_line = url_zulip.format(tag=hash_or_tag, name='zulip', version=version) _edit_reqs_file(prod, zulip_bots_line, zulip_line) _edit_reqs_file(dev, zulip_bots_line, zulip_line) @@ -135,6 +134,7 @@ def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag): message = 'Updated zulip API package requirements in the main repo.' print(crayons.white(message, bold=True)) + def parse_args(): usage = """ Script to automate the PyPA release of the zulip, zulip_bots and @@ -176,26 +176,36 @@ And you're done! Congrats! """ parser = argparse.ArgumentParser(usage=usage) - parser.add_argument('--cleanup', '-c', - action='store_true', - default=False, - help='Remove build directories (dist/, build/, egg-info/, etc).') + parser.add_argument( + '--cleanup', + '-c', + action='store_true', + default=False, + help='Remove build directories (dist/, build/, egg-info/, etc).', + ) - parser.add_argument('--build', '-b', - metavar='VERSION_NUM', - help=('Build sdists and wheels for all packages with the' - 'specified version number.' - ' sdists and wheels are stored in /dist/*.')) + parser.add_argument( + '--build', + '-b', + metavar='VERSION_NUM', + help=( + 'Build sdists and wheels for all packages with the' + 'specified version number.' + ' sdists and wheels are stored in /dist/*.' + ), + ) - parser.add_argument('--release', '-r', - action='store_true', - default=False, - help='Upload the packages to PyPA using twine.') + parser.add_argument( + '--release', + '-r', + action='store_true', + default=False, + help='Upload the packages to PyPA using twine.', + ) subparsers = parser.add_subparsers(dest='subcommand') parser_main_repo = subparsers.add_parser( - 'update-main-repo', - help='Update the zulip/requirements/* in the main zulip repo.' + 'update-main-repo', help='Update the zulip/requirements/* in the main zulip repo.' ) parser_main_repo.add_argument('repo', metavar='PATH_TO_ZULIP_DIR') parser_main_repo.add_argument('version', metavar='version number of the packages') @@ -203,6 +213,7 @@ And you're done! Congrats! return parser.parse_args() + def main(): options = parse_args() @@ -239,11 +250,10 @@ def main(): if options.subcommand == 'update-main-repo': if options.hash: - update_requirements_in_zulip_repo(options.repo, options.version, - options.hash) + update_requirements_in_zulip_repo(options.repo, options.version, options.hash) else: - update_requirements_in_zulip_repo(options.repo, options.version, - options.version) + update_requirements_in_zulip_repo(options.repo, options.version, options.version) + if __name__ == '__main__': main() diff --git a/tools/review b/tools/review index b71a4f3..acffa9a 100755 --- a/tools/review +++ b/tools/review @@ -9,24 +9,29 @@ def exit(message: str) -> None: print(message) sys.exit(1) + def run(command: str) -> None: print('\n>>> ' + command) subprocess.check_call(command.split()) + def check_output(command: str) -> str: return subprocess.check_output(command.split()).decode('ascii') + def get_git_branch() -> str: command = 'git rev-parse --abbrev-ref HEAD' output = check_output(command) return output.strip() + def check_git_pristine() -> None: command = 'git status --porcelain' output = check_output(command) if output.strip(): exit('Git is not pristine:\n' + output) + def ensure_on_clean_master() -> None: branch = get_git_branch() if branch != 'master': @@ -35,6 +40,7 @@ def ensure_on_clean_master() -> None: run('git fetch upstream master') run('git rebase upstream/master') + def create_pull_branch(pull_id: int) -> None: run('git fetch upstream pull/%d/head' % (pull_id,)) run('git checkout -B review-%s FETCH_HEAD' % (pull_id,)) @@ -44,8 +50,8 @@ def create_pull_branch(pull_id: int) -> None: print() print('PR: %d' % (pull_id,)) - print(subprocess.check_output(['git', 'log', 'HEAD~..', - '--pretty=format:Author: %an'])) + print(subprocess.check_output(['git', 'log', 'HEAD~..', '--pretty=format:Author: %an'])) + def review_pr() -> None: try: @@ -56,5 +62,6 @@ def review_pr() -> None: ensure_on_clean_master() create_pull_branch(pull_id) + if __name__ == '__main__': review_pr() diff --git a/tools/run-mypy b/tools/run-mypy index d25e8ad..f4f671e 100755 --- a/tools/run-mypy +++ b/tools/run-mypy @@ -99,38 +99,78 @@ force_include = [ "zulip_bots/zulip_bots/bots/front/front.py", "zulip_bots/zulip_bots/bots/front/test_front.py", "tools/custom_check.py", - "tools/deploy" + "tools/deploy", ] parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.") -parser.add_argument('targets', nargs='*', default=[], - help="""files and directories to include in the result. - If this is not specified, the current directory is used""") -parser.add_argument('-m', '--modified', action='store_true', default=False, help='list only modified files') -parser.add_argument('-a', '--all', dest='all', action='store_true', default=False, - help="""run mypy on all python files, ignoring the exclude list. - This is useful if you have to find out which files fail mypy check.""") -parser.add_argument('--no-disallow-untyped-defs', dest='disallow_untyped_defs', action='store_false', default=True, - help="""Don't throw errors when functions are not annotated""") -parser.add_argument('--scripts-only', dest='scripts_only', action='store_true', default=False, - help="""Only type check extensionless python scripts""") -parser.add_argument('--warn-unused-ignores', dest='warn_unused_ignores', action='store_true', default=False, - help="""Use the --warn-unused-ignores flag with mypy""") -parser.add_argument('--no-ignore-missing-imports', dest='ignore_missing_imports', action='store_false', default=True, - help="""Don't use the --ignore-missing-imports flag with mypy""") -parser.add_argument('--quick', action='store_true', default=False, - help="""Use the --quick flag with mypy""") +parser.add_argument( + 'targets', + nargs='*', + default=[], + help="""files and directories to include in the result. + If this is not specified, the current directory is used""", +) +parser.add_argument( + '-m', '--modified', action='store_true', default=False, help='list only modified files' +) +parser.add_argument( + '-a', + '--all', + dest='all', + action='store_true', + default=False, + help="""run mypy on all python files, ignoring the exclude list. + This is useful if you have to find out which files fail mypy check.""", +) +parser.add_argument( + '--no-disallow-untyped-defs', + dest='disallow_untyped_defs', + action='store_false', + default=True, + help="""Don't throw errors when functions are not annotated""", +) +parser.add_argument( + '--scripts-only', + dest='scripts_only', + action='store_true', + default=False, + help="""Only type check extensionless python scripts""", +) +parser.add_argument( + '--warn-unused-ignores', + dest='warn_unused_ignores', + action='store_true', + default=False, + help="""Use the --warn-unused-ignores flag with mypy""", +) +parser.add_argument( + '--no-ignore-missing-imports', + dest='ignore_missing_imports', + action='store_false', + default=True, + help="""Don't use the --ignore-missing-imports flag with mypy""", +) +parser.add_argument( + '--quick', action='store_true', default=False, help="""Use the --quick flag with mypy""" +) args = parser.parse_args() if args.all: exclude = [] # find all non-excluded files in current directory -files_dict = cast(Dict[str, List[str]], - lister.list_files(targets=args.targets, ftypes=['py', 'pyi'], - use_shebang=True, modified_only=args.modified, - exclude = exclude + ['stubs'], group_by_ftype=True, - extless_only=args.scripts_only)) +files_dict = cast( + Dict[str, List[str]], + lister.list_files( + targets=args.targets, + ftypes=['py', 'pyi'], + use_shebang=True, + modified_only=args.modified, + exclude=exclude + ['stubs'], + group_by_ftype=True, + extless_only=args.scripts_only, + ), +) for inpath in force_include: try: @@ -140,10 +180,13 @@ for inpath in force_include: files_dict[ext].append(inpath) pyi_files = set(files_dict['pyi']) -python_files = [fpath for fpath in files_dict['py'] - if not fpath.endswith('.py') or fpath + 'i' not in pyi_files] +python_files = [ + fpath for fpath in files_dict['py'] if not fpath.endswith('.py') or fpath + 'i' not in pyi_files +] -repo_python_files = OrderedDict([('zulip', []), ('zulip_bots', []), ('zulip_botserver', []), ('tools', [])]) +repo_python_files = OrderedDict( + [('zulip', []), ('zulip_bots', []), ('zulip_botserver', []), ('tools', [])] +) for file_path in python_files: repo = PurePath(file_path).parts[0] if repo in repo_python_files: diff --git a/tools/server_lib/test_handler.py b/tools/server_lib/test_handler.py index a4a9f9f..2752142 100644 --- a/tools/server_lib/test_handler.py +++ b/tools/server_lib/test_handler.py @@ -1,4 +1,3 @@ - import argparse import os import shutil @@ -10,21 +9,26 @@ import pytest TOOLS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) os.chdir(os.path.dirname(TOOLS_DIR)) + def handle_input_and_run_tests_for_package(package_name, path_list): parser = argparse.ArgumentParser(description="Run tests for {}.".format(package_name)) - parser.add_argument('--coverage', - nargs='?', - const=True, - default=False, - help='compute test coverage (--coverage combine to combine with previous reports)') - parser.add_argument('--pytest', '-p', - default=False, - action='store_true', - help="run tests with pytest") - parser.add_argument('--verbose', '-v', - default=False, - action='store_true', - help='show verbose output (with pytest)') + parser.add_argument( + '--coverage', + nargs='?', + const=True, + default=False, + help='compute test coverage (--coverage combine to combine with previous reports)', + ) + parser.add_argument( + '--pytest', '-p', default=False, action='store_true', help="run tests with pytest" + ) + parser.add_argument( + '--verbose', + '-v', + default=False, + action='store_true', + help='show verbose output (with pytest)', + ) options = parser.parse_args() test_session_title = ' Running tests for {} '.format(package_name) @@ -33,6 +37,7 @@ def handle_input_and_run_tests_for_package(package_name, path_list): if options.coverage: import coverage + cov = coverage.Coverage(config_file="tools/.coveragerc") if options.coverage == 'combine': cov.load() @@ -42,11 +47,11 @@ def handle_input_and_run_tests_for_package(package_name, path_list): location_to_run_in = os.path.join(TOOLS_DIR, '..', *path_list) paths_to_test = ['.'] pytest_options = [ - '-s', # show output from tests; this hides the progress bar though - '-x', # stop on first test failure + '-s', # show output from tests; this hides the progress bar though + '-x', # stop on first test failure '--ff', # runs last failure first ] - pytest_options += (['-v'] if options.verbose else []) + pytest_options += ['-v'] if options.verbose else [] os.chdir(location_to_run_in) result = pytest.main(paths_to_test + pytest_options) if result != 0: diff --git a/tools/test-bots b/tools/test-bots index 308dc87..5a8bfa4 100755 --- a/tools/test-bots +++ b/tools/test-bots @@ -31,33 +31,37 @@ the tests for xkcd and wikipedia bots): """ parser = argparse.ArgumentParser(description=description) - parser.add_argument('bots_to_test', - metavar='bot', - nargs='*', - default=[], - help='specific bots to test (default is all)') - parser.add_argument('--coverage', - nargs='?', - const=True, - default=False, - help='compute test coverage (--coverage combine to combine with previous reports)') - parser.add_argument('--exclude', - metavar='bot', - nargs='*', - default=[], - help='bot(s) to exclude') - parser.add_argument('--error-on-no-init', - default=False, - action="store_true", - help="whether to exit if a bot has tests which won't run due to no __init__.py") - parser.add_argument('--pytest', '-p', - default=False, - action='store_true', - help="run tests with pytest") - parser.add_argument('--verbose', '-v', - default=False, - action='store_true', - help='show verbose output (with pytest)') + parser.add_argument( + 'bots_to_test', + metavar='bot', + nargs='*', + default=[], + help='specific bots to test (default is all)', + ) + parser.add_argument( + '--coverage', + nargs='?', + const=True, + default=False, + help='compute test coverage (--coverage combine to combine with previous reports)', + ) + parser.add_argument('--exclude', metavar='bot', nargs='*', default=[], help='bot(s) to exclude') + parser.add_argument( + '--error-on-no-init', + default=False, + action="store_true", + help="whether to exit if a bot has tests which won't run due to no __init__.py", + ) + parser.add_argument( + '--pytest', '-p', default=False, action='store_true', help="run tests with pytest" + ) + parser.add_argument( + '--verbose', + '-v', + default=False, + action='store_true', + help='show verbose output (with pytest)', + ) return parser.parse_args() @@ -76,6 +80,7 @@ def main(): if options.coverage: import coverage + cov = coverage.Coverage(config_file="tools/.coveragerc") if options.coverage == 'combine': cov.load() @@ -94,11 +99,11 @@ def main(): excluded_bots = ['merels'] pytest_bots_to_test = sorted([bot for bot in bots_to_test if bot not in excluded_bots]) pytest_options = [ - '-s', # show output from tests; this hides the progress bar though - '-x', # stop on first test failure + '-s', # show output from tests; this hides the progress bar though + '-x', # stop on first test failure '--ff', # runs last failure first ] - pytest_options += (['-v'] if options.verbose else []) + pytest_options += ['-v'] if options.verbose else [] os.chdir(bots_dir) result = pytest.main(pytest_bots_to_test + pytest_options) if result != 0: @@ -116,7 +121,9 @@ def main(): test_suites.append(loader.discover(top_level + name, top_level_dir=top_level)) except ImportError as exception: print(exception) - print("This likely indicates that you need a '__init__.py' file in your bot directory.") + print( + "This likely indicates that you need a '__init__.py' file in your bot directory." + ) if options.error_on_no_init: sys.exit(1) @@ -134,5 +141,6 @@ def main(): cov.html_report() print("HTML report saved under directory 'htmlcov'.") + if __name__ == '__main__': main() diff --git a/zulip/integrations/bridge_between_zulips/interrealm_bridge_config.py b/zulip/integrations/bridge_between_zulips/interrealm_bridge_config.py index c28cd7c..13886d3 100644 --- a/zulip/integrations/bridge_between_zulips/interrealm_bridge_config.py +++ b/zulip/integrations/bridge_between_zulips/interrealm_bridge_config.py @@ -4,11 +4,13 @@ config = { "api_key": "key1", "site": "https://realm1.zulipchat.com", "stream": "bridges", - "subject": "<- realm2"}, + "subject": "<- realm2", + }, "bot_2": { "email": "tunnel-bot@realm2.zulipchat.com", "api_key": "key2", "site": "https://realm2.zulipchat.com", "stream": "bridges", - "subject": "<- realm1"} + "subject": "<- realm1", + }, } diff --git a/zulip/integrations/bridge_between_zulips/run-interrealm-bridge b/zulip/integrations/bridge_between_zulips/run-interrealm-bridge index 0a6a345..6a8d5cb 100755 --- a/zulip/integrations/bridge_between_zulips/run-interrealm-bridge +++ b/zulip/integrations/bridge_between_zulips/run-interrealm-bridge @@ -11,9 +11,9 @@ import interrealm_bridge_config import zulip -def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any], - to_bot: Dict[str, Any], stream_wide: bool - ) -> Callable[[Dict[str, Any]], None]: +def create_pipe_event( + to_client: zulip.Client, from_bot: Dict[str, Any], to_bot: Dict[str, Any], stream_wide: bool +) -> Callable[[Dict[str, Any]], None]: def _pipe_message(msg: Dict[str, Any]) -> None: isa_stream = msg["type"] == "stream" not_from_bot = msg["sender_email"] not in (from_bot["email"], to_bot["email"]) @@ -32,8 +32,9 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any], if "/user_uploads/" in msg["content"]: # Fix the upload URL of the image to be the source of where it # comes from - msg["content"] = msg["content"].replace("/user_uploads/", - from_bot["site"] + "/user_uploads/") + msg["content"] = msg["content"].replace( + "/user_uploads/", from_bot["site"] + "/user_uploads/" + ) if msg["content"].startswith(("```", "- ", "* ", "> ", "1. ")): # If a message starts with special prefixes, make sure to prepend a newline for # formatting purpose @@ -45,7 +46,7 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any], "content": "**{}**: {}".format(msg["sender_full_name"], msg["content"]), "has_attachment": msg.get("has_attachment", False), "has_image": msg.get("has_image", False), - "has_link": msg.get("has_link", False) + "has_link": msg.get("has_link", False), } print(msg_data) print(to_client.send_message(msg_data)) @@ -55,8 +56,10 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any], if event["type"] == "message": msg = event["message"] _pipe_message(msg) + return _pipe_event + if __name__ == "__main__": usage = """run-interrealm-bridge [--stream] @@ -71,20 +74,15 @@ if __name__ == "__main__": sys.path.append(os.path.join(os.path.dirname(__file__), '..')) parser = argparse.ArgumentParser(usage=usage) - parser.add_argument('--stream', - action='store_true', - help="", - default=False) + parser.add_argument('--stream', action='store_true', help="", default=False) args = parser.parse_args() options = interrealm_bridge_config.config bot1 = options["bot_1"] bot2 = options["bot_2"] - client1 = zulip.Client(email=bot1["email"], api_key=bot1["api_key"], - site=bot1["site"]) - client2 = zulip.Client(email=bot2["email"], api_key=bot2["api_key"], - site=bot2["site"]) + client1 = zulip.Client(email=bot1["email"], api_key=bot1["api_key"], site=bot1["site"]) + client2 = zulip.Client(email=bot2["email"], api_key=bot2["api_key"], site=bot2["site"]) # A bidirectional tunnel pipe_event1 = create_pipe_event(client2, bot1, bot2, args.stream) p1 = mp.Process(target=client1.call_on_each_event, args=(pipe_event1, ["message"])) diff --git a/zulip/integrations/bridge_with_irc/irc-mirror.py b/zulip/integrations/bridge_with_irc/irc-mirror.py index e74bc87..de81307 100755 --- a/zulip/integrations/bridge_with_irc/irc-mirror.py +++ b/zulip/integrations/bridge_with_irc/irc-mirror.py @@ -26,7 +26,9 @@ Note that "_zulip" will be automatically appended to the IRC nick provided """ if __name__ == "__main__": - parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage), allow_provisioning=True) + parser = zulip.add_default_arguments( + argparse.ArgumentParser(usage=usage), allow_provisioning=True + ) parser.add_argument('--irc-server', default=None) parser.add_argument('--port', default=6667) parser.add_argument('--nick-prefix', default=None) @@ -43,14 +45,24 @@ if __name__ == "__main__": from irc_mirror_backend import IRCBot except ImportError: traceback.print_exc() - print("You have unsatisfied dependencies. Install all missing dependencies with " - "{} --provision".format(sys.argv[0])) + print( + "You have unsatisfied dependencies. Install all missing dependencies with " + "{} --provision".format(sys.argv[0]) + ) sys.exit(1) if options.irc_server is None or options.nick_prefix is None or options.channel is None: parser.error("Missing required argument") nickname = options.nick_prefix + "_zulip" - bot = IRCBot(zulip_client, options.stream, options.topic, options.channel, - nickname, options.irc_server, options.nickserv_pw, options.port) + bot = IRCBot( + zulip_client, + options.stream, + options.topic, + options.channel, + nickname, + options.irc_server, + options.nickserv_pw, + options.port, + ) bot.start() diff --git a/zulip/integrations/bridge_with_irc/irc_mirror_backend.py b/zulip/integrations/bridge_with_irc/irc_mirror_backend.py index 50965d4..5ee98a8 100644 --- a/zulip/integrations/bridge_with_irc/irc_mirror_backend.py +++ b/zulip/integrations/bridge_with_irc/irc_mirror_backend.py @@ -10,8 +10,17 @@ from irc.client_aio import AioReactor class IRCBot(irc.bot.SingleServerIRCBot): reactor_class = AioReactor - def __init__(self, zulip_client: Any, stream: str, topic: str, channel: irc.bot.Channel, - nickname: str, server: str, nickserv_password: str = '', port: int = 6667) -> None: + def __init__( + self, + zulip_client: Any, + stream: str, + topic: str, + channel: irc.bot.Channel, + nickname: str, + server: str, + nickserv_password: str = '', + port: int = 6667, + ) -> None: self.channel = channel # type: irc.bot.Channel self.zulip_client = zulip_client self.stream = stream @@ -31,9 +40,7 @@ class IRCBot(irc.bot.SingleServerIRCBot): # Taken from # https://github.com/jaraco/irc/blob/master/irc/client_aio.py, # in particular the method of AioSimpleIRCClient - self.c = self.reactor.loop.run_until_complete( - self.connection.connect(*args, **kwargs) - ) + self.c = self.reactor.loop.run_until_complete(self.connection.connect(*args, **kwargs)) print("Listening now. Please send an IRC message to verify operation") def check_subscription_or_die(self) -> None: @@ -43,7 +50,10 @@ class IRCBot(irc.bot.SingleServerIRCBot): exit(1) subs = [s["name"] for s in resp["subscriptions"]] if self.stream not in subs: - print("The bot is not yet subscribed to stream '%s'. Please subscribe the bot to the stream first." % (self.stream,)) + print( + "The bot is not yet subscribed to stream '%s'. Please subscribe the bot to the stream first." + % (self.stream,) + ) exit(1) def on_nicknameinuse(self, c: ServerConnection, e: Event) -> None: @@ -70,8 +80,11 @@ class IRCBot(irc.bot.SingleServerIRCBot): else: return else: - recipients = [u["short_name"] for u in msg["display_recipient"] if - u["email"] != msg["sender_email"]] + recipients = [ + u["short_name"] + for u in msg["display_recipient"] + if u["email"] != msg["sender_email"] + ] if len(recipients) == 1: send = lambda x: self.c.privmsg(recipients[0], x) else: @@ -89,12 +102,16 @@ class IRCBot(irc.bot.SingleServerIRCBot): return # Forward the PM to Zulip - print(self.zulip_client.send_message({ - "sender": sender, - "type": "private", - "to": "username@example.com", - "content": content, - })) + print( + self.zulip_client.send_message( + { + "sender": sender, + "type": "private", + "to": "username@example.com", + "content": content, + } + ) + ) def on_pubmsg(self, c: ServerConnection, e: Event) -> None: content = e.arguments[0] @@ -103,12 +120,16 @@ class IRCBot(irc.bot.SingleServerIRCBot): return # Forward the stream message to Zulip - print(self.zulip_client.send_message({ - "type": "stream", - "to": self.stream, - "subject": self.topic, - "content": "**{}**: {}".format(sender, content), - })) + print( + self.zulip_client.send_message( + { + "type": "stream", + "to": self.stream, + "subject": self.topic, + "content": "**{}**: {}".format(sender, content), + } + ) + ) def on_dccmsg(self, c: ServerConnection, e: Event) -> None: c.privmsg("You said: " + e.arguments[0]) diff --git a/zulip/integrations/bridge_with_matrix/matrix_bridge.py b/zulip/integrations/bridge_with_matrix/matrix_bridge.py index 8722a26..6c89145 100644 --- a/zulip/integrations/bridge_with_matrix/matrix_bridge.py +++ b/zulip/integrations/bridge_with_matrix/matrix_bridge.py @@ -24,19 +24,22 @@ MATRIX_USERNAME_REGEX = '@([a-zA-Z0-9-_]+):matrix.org' ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}" MATRIX_MESSAGE_TEMPLATE = "<{username}> {message}" + class Bridge_ConfigException(Exception): pass + class Bridge_FatalMatrixException(Exception): pass + class Bridge_ZulipFatalException(Exception): pass + def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None: try: - matrix_client.login_with_password(matrix_config["username"], - matrix_config["password"]) + matrix_client.login_with_password(matrix_config["username"], matrix_config["password"]) except MatrixRequestError as exception: if exception.code == 403: raise Bridge_FatalMatrixException("Bad username or password.") @@ -45,6 +48,7 @@ def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None: except MissingSchema: raise Bridge_FatalMatrixException("Bad URL format.") + def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any: try: room = matrix_client.join_room(matrix_config["room_id"]) @@ -55,10 +59,12 @@ def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any: else: raise Bridge_FatalMatrixException("Couldn't find room.") + def die(signal: int, frame: FrameType) -> None: # We actually want to exit, so run os._exit (so as not to be caught and restarted) os._exit(1) + def matrix_to_zulip( zulip_client: zulip.Client, zulip_config: Dict[str, Any], @@ -78,12 +84,14 @@ def matrix_to_zulip( if not_from_zulip_bot and content: try: - result = zulip_client.send_message({ - "type": "stream", - "to": zulip_config["stream"], - "subject": zulip_config["topic"], - "content": content, - }) + result = zulip_client.send_message( + { + "type": "stream", + "to": zulip_config["stream"], + "subject": zulip_config["topic"], + "content": content, + } + ) except Exception as exception: # XXX This should be more specific # Generally raised when user is forbidden raise Bridge_ZulipFatalException(exception) @@ -93,6 +101,7 @@ def matrix_to_zulip( return _matrix_to_zulip + def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Optional[str]: irc_nick = shorten_irc_nick(event['sender']) if event['type'] == "m.room.member": @@ -101,19 +110,19 @@ def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Opt # Join and leave events can be noisy. They are ignored by default. # To enable these events pass `no_noise` as `False` as the script argument if event['membership'] == "join": - content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, - message="joined") + content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="joined") elif event['membership'] == "leave": - content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, - message="quit") + content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="quit") elif event['type'] == "m.room.message": if event['content']['msgtype'] == "m.text" or event['content']['msgtype'] == "m.emote": - content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, - message=event['content']['body']) + content = ZULIP_MESSAGE_TEMPLATE.format( + username=irc_nick, message=event['content']['body'] + ) else: content = event['type'] return content + def shorten_irc_nick(nick: str) -> str: """ Add nick shortner functions for specific IRC networks @@ -130,8 +139,8 @@ def shorten_irc_nick(nick: str) -> str: return match.group(1) return nick -def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, Any]], None]: +def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, Any]], None]: def _zulip_to_matrix(msg: Dict[str, Any]) -> None: """ Zulip -> Matrix @@ -139,12 +148,15 @@ def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, An message_valid = check_zulip_message_validity(msg, config) if message_valid: matrix_username = msg["sender_full_name"].replace(' ', '') - matrix_text = MATRIX_MESSAGE_TEMPLATE.format(username=matrix_username, - message=msg["content"]) + matrix_text = MATRIX_MESSAGE_TEMPLATE.format( + username=matrix_username, message=msg["content"] + ) # Forward Zulip message to Matrix room.send_text(matrix_text) + return _zulip_to_matrix + def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool: is_a_stream = msg["type"] == "stream" in_the_specified_stream = msg["display_recipient"] == config["stream"] @@ -157,6 +169,7 @@ def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> return True return False + def generate_parser() -> argparse.ArgumentParser: description = """ Script to bridge between a topic in a Zulip stream, and a Matrix channel. @@ -169,19 +182,34 @@ def generate_parser() -> argparse.ArgumentParser: * #zulip:matrix.org (zulip channel on Matrix) * #freenode_#zulip:matrix.org (zulip channel on irc.freenode.net)""" - parser = argparse.ArgumentParser(description=description, - formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument('-c', '--config', required=False, - help="Path to the config file for the bridge.") - parser.add_argument('--write-sample-config', metavar='PATH', dest='sample_config', - help="Generate a configuration template at the specified location.") - parser.add_argument('--from-zuliprc', metavar='ZULIPRC', dest='zuliprc', - help="Optional path to zuliprc file for bot, when using --write-sample-config") - parser.add_argument('--show-join-leave', dest='no_noise', - default=True, action='store_false', - help="Enable IRC join/leave events.") + parser = argparse.ArgumentParser( + description=description, formatter_class=argparse.RawTextHelpFormatter + ) + parser.add_argument( + '-c', '--config', required=False, help="Path to the config file for the bridge." + ) + parser.add_argument( + '--write-sample-config', + metavar='PATH', + dest='sample_config', + help="Generate a configuration template at the specified location.", + ) + parser.add_argument( + '--from-zuliprc', + metavar='ZULIPRC', + dest='zuliprc', + help="Optional path to zuliprc file for bot, when using --write-sample-config", + ) + parser.add_argument( + '--show-join-leave', + dest='no_noise', + default=True, + action='store_false', + help="Enable IRC join/leave events.", + ) return parser + def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]: config = configparser.ConfigParser() @@ -197,25 +225,40 @@ def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]: return {section: dict(config[section]) for section in config.sections()} + def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None: if os.path.exists(target_path): - raise Bridge_ConfigException("Path '{}' exists; not overwriting existing file.".format(target_path)) + raise Bridge_ConfigException( + "Path '{}' exists; not overwriting existing file.".format(target_path) + ) - sample_dict = OrderedDict(( - ('matrix', OrderedDict(( - ('host', 'https://matrix.org'), - ('username', 'username'), - ('password', 'password'), - ('room_id', '#zulip:matrix.org'), - ))), - ('zulip', OrderedDict(( - ('email', 'glitch-bot@chat.zulip.org'), - ('api_key', 'aPiKeY'), - ('site', 'https://chat.zulip.org'), - ('stream', 'test here'), - ('topic', 'matrix'), - ))), - )) + sample_dict = OrderedDict( + ( + ( + 'matrix', + OrderedDict( + ( + ('host', 'https://matrix.org'), + ('username', 'username'), + ('password', 'password'), + ('room_id', '#zulip:matrix.org'), + ) + ), + ), + ( + 'zulip', + OrderedDict( + ( + ('email', 'glitch-bot@chat.zulip.org'), + ('api_key', 'aPiKeY'), + ('site', 'https://chat.zulip.org'), + ('stream', 'test here'), + ('topic', 'matrix'), + ) + ), + ), + ) + ) if zuliprc is not None: if not os.path.exists(zuliprc): @@ -238,6 +281,7 @@ def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None: with open(target_path, 'w') as target: sample.write(target) + def main() -> None: signal.signal(signal.SIGINT, die) logging.basicConfig(level=logging.WARNING) @@ -254,8 +298,11 @@ def main() -> None: if options.zuliprc is None: print("Wrote sample configuration to '{}'".format(options.sample_config)) else: - print("Wrote sample configuration to '{}' using zuliprc file '{}'" - .format(options.sample_config, options.zuliprc)) + print( + "Wrote sample configuration to '{}' using zuliprc file '{}'".format( + options.sample_config, options.zuliprc + ) + ) sys.exit(0) elif not options.config: print("Options required: -c or --config to run, OR --write-sample-config.") @@ -277,9 +324,11 @@ def main() -> None: while backoff.keep_going(): print("Starting matrix mirroring bot") try: - zulip_client = zulip.Client(email=zulip_config["email"], - api_key=zulip_config["api_key"], - site=zulip_config["site"]) + zulip_client = zulip.Client( + email=zulip_config["email"], + api_key=zulip_config["api_key"], + site=zulip_config["site"], + ) matrix_client = MatrixClient(matrix_config["host"]) # Login to Matrix @@ -287,8 +336,9 @@ def main() -> None: # Join a room in Matrix room = matrix_join_room(matrix_client, matrix_config) - room.add_listener(matrix_to_zulip(zulip_client, zulip_config, matrix_config, - options.no_noise)) + room.add_listener( + matrix_to_zulip(zulip_client, zulip_config, matrix_config, options.no_noise) + ) print("Starting listener thread on Matrix client") matrix_client.start_listener_thread() @@ -306,5 +356,6 @@ def main() -> None: traceback.print_exc() backoff.fail() + if __name__ == '__main__': main() diff --git a/zulip/integrations/bridge_with_matrix/test_matrix.py b/zulip/integrations/bridge_with_matrix/test_matrix.py index b49409c..8cac64b 100644 --- a/zulip/integrations/bridge_with_matrix/test_matrix.py +++ b/zulip/integrations/bridge_with_matrix/test_matrix.py @@ -30,22 +30,26 @@ topic = matrix """ + @contextmanager def new_temp_dir() -> Iterator[str]: path = mkdtemp() yield path shutil.rmtree(path) + class MatrixBridgeScriptTests(TestCase): def output_from_script(self, options: List[str]) -> List[str]: - popen = Popen(["python", script] + options, stdin=PIPE, stdout=PIPE, universal_newlines=True) + popen = Popen( + ["python", script] + options, stdin=PIPE, stdout=PIPE, universal_newlines=True + ) return popen.communicate()[0].strip().split("\n") def test_no_args(self) -> None: output_lines = self.output_from_script([]) expected_lines = [ "Options required: -c or --config to run, OR --write-sample-config.", - "usage: {} [-h]".format(script_file) + "usage: {} [-h]".format(script_file), ] for expected, output in zip(expected_lines, output_lines): self.assertIn(expected, output) @@ -74,19 +78,27 @@ class MatrixBridgeScriptTests(TestCase): def test_write_sample_config_from_zuliprc(self) -> None: zuliprc_template = ["[api]", "email={email}", "key={key}", "site={site}"] - zulip_params = {'email': 'foo@bar', - 'key': 'some_api_key', - 'site': 'https://some.chat.serverplace'} + zulip_params = { + 'email': 'foo@bar', + 'key': 'some_api_key', + 'site': 'https://some.chat.serverplace', + } with new_temp_dir() as tempdir: path = os.path.join(tempdir, sample_config_path) zuliprc_path = os.path.join(tempdir, "zuliprc") with open(zuliprc_path, "w") as zuliprc_file: zuliprc_file.write("\n".join(zuliprc_template).format(**zulip_params)) - output_lines = self.output_from_script(["--write-sample-config", path, - "--from-zuliprc", zuliprc_path]) - self.assertEqual(output_lines, - ["Wrote sample configuration to '{}' using zuliprc file '{}'" - .format(path, zuliprc_path)]) + output_lines = self.output_from_script( + ["--write-sample-config", path, "--from-zuliprc", zuliprc_path] + ) + self.assertEqual( + output_lines, + [ + "Wrote sample configuration to '{}' using zuliprc file '{}'".format( + path, zuliprc_path + ) + ], + ) with open(path) as sample_file: sample_lines = [line.strip() for line in sample_file.readlines()] @@ -101,23 +113,26 @@ class MatrixBridgeScriptTests(TestCase): path = os.path.join(tempdir, sample_config_path) zuliprc_path = os.path.join(tempdir, "zuliprc") # No writing of zuliprc file here -> triggers check for zuliprc absence - output_lines = self.output_from_script(["--write-sample-config", path, - "--from-zuliprc", zuliprc_path]) - self.assertEqual(output_lines, - ["Could not write sample config: Zuliprc file '{}' does not exist." - .format(zuliprc_path)]) + output_lines = self.output_from_script( + ["--write-sample-config", path, "--from-zuliprc", zuliprc_path] + ) + self.assertEqual( + output_lines, + [ + "Could not write sample config: Zuliprc file '{}' does not exist.".format( + zuliprc_path + ) + ], + ) + class MatrixBridgeZulipToMatrixTests(TestCase): - valid_zulip_config = dict( - stream="some stream", - topic="some topic", - email="some@email" - ) + valid_zulip_config = dict(stream="some stream", topic="some topic", email="some@email") valid_msg = dict( sender_email="John@Smith.smith", # must not be equal to config:email type="stream", # Can only mirror Zulip streams display_recipient=valid_zulip_config['stream'], - subject=valid_zulip_config['topic'] + subject=valid_zulip_config['topic'], ) def test_zulip_message_validity_success(self) -> None: diff --git a/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py b/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py index ce41786..2a85a61 100644 --- a/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py +++ b/zulip/integrations/bridge_with_slack/bridge_with_slack_config.py @@ -10,5 +10,5 @@ config = { "username": "slack username", "token": "slack token", "channel": "C5Z5N7R8A -- must be channel id", - } + }, } diff --git a/zulip/integrations/bridge_with_slack/run-slack-bridge b/zulip/integrations/bridge_with_slack/run-slack-bridge index 6b7b364..31d67c6 100755 --- a/zulip/integrations/bridge_with_slack/run-slack-bridge +++ b/zulip/integrations/bridge_with_slack/run-slack-bridge @@ -18,6 +18,7 @@ import zulip ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}" SLACK_MESSAGE_TEMPLATE = "<{username}> {message}" + def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool: is_a_stream = msg["type"] == "stream" in_the_specified_stream = msg["display_recipient"] == config["stream"] @@ -30,6 +31,7 @@ def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> return True return False + class SlackBridge: def __init__(self, config: Dict[str, Any]) -> None: self.config = config @@ -40,7 +42,8 @@ class SlackBridge: self.zulip_client = zulip.Client( email=self.zulip_config["email"], api_key=self.zulip_config["api_key"], - site=self.zulip_config["site"]) + site=self.zulip_config["site"], + ) self.zulip_stream = self.zulip_config["stream"] self.zulip_subject = self.zulip_config["topic"] @@ -69,18 +72,22 @@ class SlackBridge: message_valid = check_zulip_message_validity(msg, self.zulip_config) if message_valid: self.wrap_slack_mention_with_bracket(msg) - slack_text = SLACK_MESSAGE_TEMPLATE.format(username=msg["sender_full_name"], - message=msg["content"]) + slack_text = SLACK_MESSAGE_TEMPLATE.format( + username=msg["sender_full_name"], message=msg["content"] + ) self.slack_webclient.chat_postMessage( channel=self.channel, text=slack_text, ) + return _zulip_to_slack def run_slack_listener(self) -> None: members = self.slack_webclient.users_list()['members'] # See also https://api.slack.com/changelog/2017-09-the-one-about-usernames - self.slack_id_to_name = {u["id"]: u["profile"].get("display_name", u["profile"]["real_name"]) for u in members} + self.slack_id_to_name = { + u["id"]: u["profile"].get("display_name", u["profile"]["real_name"]) for u in members + } self.slack_name_to_id = {v: k for k, v in self.slack_id_to_name.items()} @RTMClient.run_on(event='message') @@ -96,14 +103,13 @@ class SlackBridge: self.replace_slack_id_with_name(msg) content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=msg['text']) msg_data = dict( - type="stream", - to=self.zulip_stream, - subject=self.zulip_subject, - content=content) + type="stream", to=self.zulip_stream, subject=self.zulip_subject, content=content + ) self.zulip_client.send_message(msg_data) self.slack_client.start() + if __name__ == "__main__": usage = """run-slack-bridge @@ -124,7 +130,9 @@ if __name__ == "__main__": try: sb = SlackBridge(config) - zp = threading.Thread(target=sb.zulip_client.call_on_each_message, args=(sb.zulip_to_slack(),)) + zp = threading.Thread( + target=sb.zulip_client.call_on_each_message, args=(sb.zulip_to_slack(),) + ) sp = threading.Thread(target=sb.run_slack_listener, args=()) print("Starting message handler on Zulip client") zp.start() diff --git a/zulip/integrations/codebase/zulip_codebase_mirror b/zulip/integrations/codebase/zulip_codebase_mirror index 8904f46..d787b9c 100755 --- a/zulip/integrations/codebase/zulip_codebase_mirror +++ b/zulip/integrations/codebase/zulip_codebase_mirror @@ -39,7 +39,8 @@ client = zulip.Client( email=config.ZULIP_USER, site=config.ZULIP_SITE, api_key=config.ZULIP_API_KEY, - client="ZulipCodebase/" + VERSION) + client="ZulipCodebase/" + VERSION, +) user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)" # find some form of JSON loader/dumper, with a preference order for speed. @@ -52,13 +53,18 @@ while len(json_implementations): except ImportError: continue + def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]: - response = requests.get("https://api3.codebasehq.com/%s" % (path,), - auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY), - params={'raw': 'True'}, - headers = {"User-Agent": user_agent, - "Content-Type": "application/json", - "Accept": "application/json"}) + response = requests.get( + "https://api3.codebasehq.com/%s" % (path,), + auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY), + params={'raw': 'True'}, + headers={ + "User-Agent": user_agent, + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) if response.status_code == 200: return json.loads(response.text) @@ -69,12 +75,16 @@ def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]: logging.error("Bad authorization from Codebase. Please check your credentials") sys.exit(-1) else: - logging.warn("Found non-success response status code: %s %s" % (response.status_code, response.text)) + logging.warn( + "Found non-success response status code: %s %s" % (response.status_code, response.text) + ) return None + def make_url(path: str) -> str: return "%s/%s" % (config.CODEBASE_ROOT_URL, path) + def handle_event(event: Dict[str, Any]) -> None: event = event['event'] event_type = event['type'] @@ -114,11 +124,17 @@ def handle_event(event: Dict[str, Any]) -> None: else: if new_ref: branch = "new branch %s" % (branch,) - content = ("%s pushed %s commit(s) to %s in project %s:\n\n" % - (actor_name, num_commits, branch, project)) + content = "%s pushed %s commit(s) to %s in project %s:\n\n" % ( + actor_name, + num_commits, + branch, + project, + ) for commit in raw_props.get('commits'): ref = commit.get('ref') - url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, ref)) + url = make_url( + "projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, ref) + ) message = commit.get('message') content += "* [%s](%s): %s\n" % (ref, url, message) elif event_type == 'ticketing_ticket': @@ -133,8 +149,10 @@ def handle_event(event: Dict[str, Any]) -> None: if assignee is None: assignee = "no one" subject = "#%s: %s" % (num, name) - content = ("""%s created a new ticket [#%s](%s) priority **%s** assigned to %s:\n\n~~~ quote\n %s""" % - (actor_name, num, url, priority, assignee, name)) + content = ( + """%s created a new ticket [#%s](%s) priority **%s** assigned to %s:\n\n~~~ quote\n %s""" + % (actor_name, num, url, priority, assignee, name) + ) elif event_type == 'ticketing_note': stream = config.ZULIP_TICKETS_STREAM_NAME @@ -148,11 +166,19 @@ def handle_event(event: Dict[str, Any]) -> None: content = "" if body is not None and len(body) > 0: - content = "%s added a comment to ticket [#%s](%s):\n\n~~~ quote\n%s\n\n" % (actor_name, num, url, body) + content = "%s added a comment to ticket [#%s](%s):\n\n~~~ quote\n%s\n\n" % ( + actor_name, + num, + url, + body, + ) if 'status_id' in changes: status_change = changes.get('status_id') - content += "Status changed from **%s** to **%s**\n\n" % (status_change[0], status_change[1]) + content += "Status changed from **%s** to **%s**\n\n" % ( + status_change[0], + status_change[1], + ) elif event_type == 'ticketing_milestone': stream = config.ZULIP_TICKETS_STREAM_NAME @@ -172,10 +198,17 @@ def handle_event(event: Dict[str, Any]) -> None: if commit: repo_link = raw_props.get('repository_permalink') - url = make_url('projects/%s/repositories/%s/commit/%s' % (project_link, repo_link, commit)) + url = make_url( + 'projects/%s/repositories/%s/commit/%s' % (project_link, repo_link, commit) + ) subject = "%s commented on %s" % (actor_name, commit) - content = "%s commented on [%s](%s):\n\n~~~ quote\n%s" % (actor_name, commit, url, comment) + content = "%s commented on [%s](%s):\n\n~~~ quote\n%s" % ( + actor_name, + commit, + url, + comment, + ) else: # Otherwise, this is a Discussion item, and handle it subj = raw_props.get("subject") @@ -199,17 +232,32 @@ def handle_event(event: Dict[str, Any]) -> None: servers = raw_props.get('servers') repo_link = raw_props.get('repository_permalink') - start_ref_url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_ref)) - end_ref_url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, end_ref)) - between_url = make_url("projects/%s/repositories/%s/compare/%s...%s" % ( - project_link, repo_link, start_ref, end_ref)) + start_ref_url = make_url( + "projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_ref) + ) + end_ref_url = make_url( + "projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, end_ref) + ) + between_url = make_url( + "projects/%s/repositories/%s/compare/%s...%s" + % (project_link, repo_link, start_ref, end_ref) + ) subject = "Deployment to %s" % (environment,) - content = ("%s deployed [%s](%s) [through](%s) [%s](%s) to the **%s** environment." % - (actor_name, start_ref, start_ref_url, between_url, end_ref, end_ref_url, environment)) + content = "%s deployed [%s](%s) [through](%s) [%s](%s) to the **%s** environment." % ( + actor_name, + start_ref, + start_ref_url, + between_url, + end_ref, + end_ref_url, + environment, + ) if servers is not None: - content += "\n\nServers deployed to: %s" % (", ".join(["`%s`" % (server,) for server in servers])) + content += "\n\nServers deployed to: %s" % ( + ", ".join(["`%s`" % (server,) for server in servers]) + ) elif event_type == 'named_tree': # Docs say named_tree type used for new/deleting branches and tags, @@ -228,10 +276,9 @@ def handle_event(event: Dict[str, Any]) -> None: if len(subject) > 60: subject = subject[:57].rstrip() + '...' - res = client.send_message({"type": "stream", - "to": stream, - "subject": subject, - "content": content}) + res = client.send_message( + {"type": "stream", "to": stream, "subject": subject, "content": content} + ) if res['result'] == 'success': logging.info("Successfully sent Zulip with id: %s" % (res['id'],)) else: @@ -278,6 +325,7 @@ def run_mirror() -> None: open(config.RESUME_FILE, 'w').write(since.strftime("%s")) logging.info("Shutting down Codebase mirror") + # void function that checks the permissions of the files this script needs. def check_permissions() -> None: # check that the log file can be written @@ -291,9 +339,12 @@ def check_permissions() -> None: try: open(config.RESUME_FILE, "a+") except OSError as e: - sys.stderr.write("Could not open up the file %s for reading and writing" % (config.RESUME_FILE,)) + sys.stderr.write( + "Could not open up the file %s for reading and writing" % (config.RESUME_FILE,) + ) sys.stderr.write(str(e)) + if __name__ == "__main__": assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue" check_permissions() diff --git a/zulip/integrations/git/post-receive b/zulip/integrations/git/post-receive index f2772af..624d843 100755 --- a/zulip/integrations/git/post-receive +++ b/zulip/integrations/git/post-receive @@ -29,30 +29,33 @@ client = zulip.Client( email=config.ZULIP_USER, site=config.ZULIP_SITE, api_key=config.ZULIP_API_KEY, - client="ZulipGit/" + VERSION) + client="ZulipGit/" + VERSION, +) + def git_repository_name() -> Text: output = subprocess.check_output(["git", "rev-parse", "--is-bare-repository"]) if output.strip() == "true": - return os.path.basename(os.getcwd())[:-len(".git")] + return os.path.basename(os.getcwd())[: -len(".git")] else: return os.path.basename(os.path.dirname(os.getcwd())) + def git_commit_range(oldrev: str, newrev: str) -> str: - log_cmd = ["git", "log", "--reverse", - "--pretty=%aE %H %s", "%s..%s" % (oldrev, newrev)] + log_cmd = ["git", "log", "--reverse", "--pretty=%aE %H %s", "%s..%s" % (oldrev, newrev)] commits = '' for ln in subprocess.check_output(log_cmd, universal_newlines=True).splitlines(): - author_email, commit_id, subject = ln.split(None, 2) + author_email, commit_id, subject = ln.split(None, 2) if hasattr(config, "format_commit_message"): commits += config.format_commit_message(author_email, subject, commit_id) else: commits += '!avatar(%s) %s\n' % (author_email, subject) return commits + def send_bot_message(oldrev: str, newrev: str, refname: str) -> None: - repo_name = git_repository_name() - branch = refname.replace('refs/heads/', '') + repo_name = git_repository_name() + branch = refname.replace('refs/heads/', '') destination = config.commit_notice_destination(repo_name, branch, newrev) if destination is None: # Don't forward the notice anywhere @@ -69,7 +72,7 @@ def send_bot_message(oldrev: str, newrev: str, refname: str) -> None: added = '' removed = '' else: - added = git_commit_range(oldrev, newrev) + added = git_commit_range(oldrev, newrev) removed = git_commit_range(newrev, oldrev) if oldrev == '0000000000000000000000000000000000000000': @@ -95,6 +98,7 @@ def send_bot_message(oldrev: str, newrev: str, refname: str) -> None: } client.send_message(message_data) + for ln in sys.stdin: oldrev, newrev, refname = ln.strip().split() send_bot_message(oldrev, newrev, refname) diff --git a/zulip/integrations/git/zulip_git_config.py b/zulip/integrations/git/zulip_git_config.py index 2a592b1..282d156 100644 --- a/zulip/integrations/git/zulip_git_config.py +++ b/zulip/integrations/git/zulip_git_config.py @@ -25,12 +25,12 @@ ZULIP_API_KEY = "0123456789abcdef0123456789abcdef" # And similarly for branch "test-post-receive" (for use when testing). def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optional[Dict[Text, Text]]: if branch in ["master", "test-post-receive"]: - return dict(stream = STREAM_NAME, - subject = "%s" % (branch,)) + return dict(stream=STREAM_NAME, subject="%s" % (branch,)) # Return None for cases where you don't want a notice sent return None + # Modify this function to change how commits are displayed; the most # common customization is to include a link to the commit in your # graphical repository viewer, e.g. @@ -39,6 +39,7 @@ def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optiona def format_commit_message(author: Text, subject: Text, commit_id: Text) -> Text: return '!avatar(%s) %s\n' % (author, subject) + ## If properly installed, the Zulip API should be in your import ## path, but if not, set a custom path below ZULIP_API_PATH: Optional[str] = None diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index 883c015..bbe286e 100644 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -7,7 +7,10 @@ from oauth2client.file import Storage try: import argparse - flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args() # type: Optional[argparse.Namespace] + + flags = argparse.ArgumentParser( + parents=[tools.argparser] + ).parse_args() # type: Optional[argparse.Namespace] except ImportError: flags = None @@ -22,6 +25,7 @@ CLIENT_SECRET_FILE = 'client_secret.json' APPLICATION_NAME = 'Zulip Calendar Bot' HOME_DIR = os.path.expanduser('~') + def get_credentials() -> client.Credentials: """Gets valid user credentials from storage. @@ -32,8 +36,7 @@ def get_credentials() -> client.Credentials: Credentials, the obtained credential. """ - credential_path = os.path.join(HOME_DIR, - 'google-credentials.json') + credential_path = os.path.join(HOME_DIR, 'google-credentials.json') store = Storage(credential_path) credentials = store.get() @@ -49,4 +52,5 @@ def get_credentials() -> client.Credentials: credentials = tools.run(flow, store) print('Storing credentials to ' + credential_path) + get_credentials() diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 518a206..ca13670 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -38,7 +38,9 @@ sent = set() # type: Set[Tuple[int, datetime.datetime]] sys.path.append(os.path.dirname(__file__)) -parser = zulip.add_default_arguments(argparse.ArgumentParser(r""" +parser = zulip.add_default_arguments( + argparse.ArgumentParser( + r""" google-calendar --calendar calendarID@example.calendar.google.com @@ -53,23 +55,29 @@ google-calendar --calendar calendarID@example.calendar.google.com revealed to local users through the command line. Depends on: google-api-python-client -""")) +""" + ) +) -parser.add_argument('--interval', - dest='interval', - default=30, - type=int, - action='store', - help='Minutes before event for reminder [default: 30]', - metavar='MINUTES') +parser.add_argument( + '--interval', + dest='interval', + default=30, + type=int, + action='store', + help='Minutes before event for reminder [default: 30]', + metavar='MINUTES', +) -parser.add_argument('--calendar', - dest = 'calendarID', - default = 'primary', - type = str, - action = 'store', - help = 'Calendar ID for the calendar you want to receive reminders from.') +parser.add_argument( + '--calendar', + dest='calendarID', + default='primary', + type=str, + action='store', + help='Calendar ID for the calendar you want to receive reminders from.', +) options = parser.parse_args() @@ -78,6 +86,7 @@ if not (options.zulip_email): zulip_client = zulip.init_from_options(options) + def get_credentials() -> client.Credentials: """Gets valid user credentials from storage. @@ -89,8 +98,7 @@ def get_credentials() -> client.Credentials: Credentials, the obtained credential. """ try: - credential_path = os.path.join(HOME_DIR, - 'google-credentials.json') + credential_path = os.path.join(HOME_DIR, 'google-credentials.json') store = Storage(credential_path) credentials = store.get() @@ -110,8 +118,17 @@ def populate_events() -> Optional[None]: service = discovery.build('calendar', 'v3', http=creds) now = datetime.datetime.now(pytz.utc).isoformat() - feed = service.events().list(calendarId=options.calendarID, timeMin=now, maxResults=5, - singleEvents=True, orderBy='startTime').execute() + feed = ( + service.events() + .list( + calendarId=options.calendarID, + timeMin=now, + maxResults=5, + singleEvents=True, + orderBy='startTime', + ) + .execute() + ) events = [] for event in feed["items"]: @@ -172,14 +189,13 @@ def send_reminders() -> Optional[None]: else: message = 'Reminder:\n\n' + '\n'.join('* ' + m for m in messages) - zulip_client.send_message(dict( - type = 'private', - to = options.zulip_email, - sender = options.zulip_email, - content = message)) + zulip_client.send_message( + dict(type='private', to=options.zulip_email, sender=options.zulip_email, content=message) + ) sent.update(keys) + # Loop forever for i in itertools.count(): try: diff --git a/zulip/integrations/hg/zulip_changegroup.py b/zulip/integrations/hg/zulip_changegroup.py index 71d4e25..4bd9f7e 100755 --- a/zulip/integrations/hg/zulip_changegroup.py +++ b/zulip/integrations/hg/zulip_changegroup.py @@ -15,7 +15,10 @@ import zulip VERSION = "0.9" -def format_summary_line(web_url: str, user: str, base: int, tip: int, branch: str, node: Text) -> Text: + +def format_summary_line( + web_url: str, user: str, base: int, tip: int, branch: str, node: Text +) -> Text: """ Format the first line of the message, which contains summary information about the changeset and links to the changelog if a @@ -29,16 +32,18 @@ def format_summary_line(web_url: str, user: str, base: int, tip: int, branch: st if web_url: shortlog_base_url = web_url.rstrip("/") + "/shortlog/" summary_url = "{shortlog}{tip}?revcount={revcount}".format( - shortlog=shortlog_base_url, tip=tip - 1, revcount=revcount) + shortlog=shortlog_base_url, tip=tip - 1, revcount=revcount + ) formatted_commit_count = "[{revcount} commit{s}]({url})".format( - revcount=revcount, s=plural, url=summary_url) + revcount=revcount, s=plural, url=summary_url + ) else: - formatted_commit_count = "{revcount} commit{s}".format( - revcount=revcount, s=plural) + formatted_commit_count = "{revcount} commit{s}".format(revcount=revcount, s=plural) return "**{user}** pushed {commits} to **{branch}** (`{tip}:{node}`):\n\n".format( - user=user, commits=formatted_commit_count, branch=branch, tip=tip, - node=node[:12]) + user=user, commits=formatted_commit_count, branch=branch, tip=tip, node=node[:12] + ) + def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str: """ @@ -56,8 +61,7 @@ def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str: if web_url: summary_url = rev_base_url + str(rev_ctx) - summary = "* [{summary}]({url})".format( - summary=one_liner, url=summary_url) + summary = "* [{summary}]({url})".format(summary=one_liner, url=summary_url) else: summary = "* {summary}".format(summary=one_liner) @@ -65,14 +69,17 @@ def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str: return "\n".join(summary for summary in commit_summaries) -def send_zulip(email: str, api_key: str, site: str, stream: str, subject: str, content: Text) -> None: + +def send_zulip( + email: str, api_key: str, site: str, stream: str, subject: str, content: Text +) -> None: """ Send a message to Zulip using the provided credentials, which should be for a bot in most cases. """ - client = zulip.Client(email=email, api_key=api_key, - site=site, - client="ZulipMercurial/" + VERSION) + client = zulip.Client( + email=email, api_key=api_key, site=site, client="ZulipMercurial/" + VERSION + ) message_data = { "type": "stream", @@ -83,6 +90,7 @@ def send_zulip(email: str, api_key: str, site: str, stream: str, subject: str, c client.send_message(message_data) + def get_config(ui: ui, item: str) -> str: try: # config returns configuration value. @@ -91,6 +99,7 @@ def get_config(ui: ui, item: str) -> str: ui.warn("Zulip: Could not find required item {} in hg config.".format(item)) sys.exit(1) + def hook(ui: ui, repo: repo, **kwargs: Text) -> None: """ Invoked by configuring a [hook] entry in .hg/hgrc. diff --git a/zulip/integrations/jabber/jabber_mirror.py b/zulip/integrations/jabber/jabber_mirror.py index ad6efc8..47cab5b 100755 --- a/zulip/integrations/jabber/jabber_mirror.py +++ b/zulip/integrations/jabber/jabber_mirror.py @@ -14,6 +14,7 @@ def die(signal: int, frame: FrameType) -> None: """We actually want to exit, so run os._exit (so as not to be caught and restarted)""" os._exit(1) + signal.signal(signal.SIGINT, die) args = [os.path.join(os.path.dirname(sys.argv[0]), "jabber_mirror_backend.py")] diff --git a/zulip/integrations/jabber/jabber_mirror_backend.py b/zulip/integrations/jabber/jabber_mirror_backend.py index ed4b778..d9b6cae 100755 --- a/zulip/integrations/jabber/jabber_mirror_backend.py +++ b/zulip/integrations/jabber/jabber_mirror_backend.py @@ -52,18 +52,22 @@ from zulip import Client __version__ = "1.1" + def room_to_stream(room: str) -> str: return room + "/xmpp" + def stream_to_room(stream: str) -> str: return stream.lower().rpartition("/xmpp")[0] + def jid_to_zulip(jid: JID) -> str: suffix = '' if not jid.username.endswith("-bot"): suffix = options.zulip_email_suffix return "%s%s@%s" % (jid.username, suffix, options.zulip_domain) + def zulip_to_jid(email: str, jabber_domain: str) -> JID: jid = JID(email, domain=jabber_domain) if ( @@ -74,6 +78,7 @@ def zulip_to_jid(email: str, jabber_domain: str) -> JID: jid.username = jid.username.rpartition(options.zulip_email_suffix)[0] return jid + class JabberToZulipBot(ClientXMPP): def __init__(self, jid: JID, password: str, rooms: List[str]) -> None: if jid.resource: @@ -153,10 +158,10 @@ class JabberToZulipBot(ClientXMPP): recipient = jid_to_zulip(msg["to"]) zulip_message = dict( - sender = sender, - type = "private", - to = recipient, - content = msg["body"], + sender=sender, + type="private", + to=recipient, + content=msg["body"], ) ret = self.zulipToJabber.client.send_message(zulip_message) if ret.get("result") != "success": @@ -178,12 +183,12 @@ class JabberToZulipBot(ClientXMPP): jid = self.nickname_to_jid(msg.get_mucroom(), sender_nick) sender = jid_to_zulip(jid) zulip_message = dict( - forged = "yes", - sender = sender, - type = "stream", - subject = subject, - to = stream, - content = msg["body"], + forged="yes", + sender=sender, + type="stream", + subject=subject, + to=stream, + content=msg["body"], ) ret = self.zulipToJabber.client.send_message(zulip_message) if ret.get("result") != "success": @@ -191,11 +196,12 @@ class JabberToZulipBot(ClientXMPP): def nickname_to_jid(self, room: str, nick: str) -> JID: jid = self.plugin['xep_0045'].getJidProperty(room, nick, "jid") - if (jid is None or jid == ''): + if jid is None or jid == '': return JID(local=nick.replace(' ', ''), domain=self.boundjid.domain) else: return jid + class ZulipToJabberBot: def __init__(self, zulip_client: Client) -> None: self.client = zulip_client @@ -221,7 +227,7 @@ class ZulipToJabberBot: self.process_subscription(event) def stream_message(self, msg: Dict[str, str]) -> None: - assert(self.jabber is not None) + assert self.jabber is not None stream = msg['display_recipient'] if not stream.endswith("/xmpp"): return @@ -229,14 +235,13 @@ class ZulipToJabberBot: room = stream_to_room(stream) jabber_recipient = JID(local=room, domain=options.conference_domain) outgoing = self.jabber.make_message( - mto = jabber_recipient, - mbody = msg['content'], - mtype = 'groupchat') + mto=jabber_recipient, mbody=msg['content'], mtype='groupchat' + ) outgoing['thread'] = '\u1FFFE' outgoing.send() def private_message(self, msg: Dict[str, Any]) -> None: - assert(self.jabber is not None) + assert self.jabber is not None for recipient in msg['display_recipient']: if recipient["email"] == self.client.email: continue @@ -245,14 +250,13 @@ class ZulipToJabberBot: recip_email = recipient['email'] jabber_recipient = zulip_to_jid(recip_email, self.jabber.boundjid.domain) outgoing = self.jabber.make_message( - mto = jabber_recipient, - mbody = msg['content'], - mtype = 'chat') + mto=jabber_recipient, mbody=msg['content'], mtype='chat' + ) outgoing['thread'] = '\u1FFFE' outgoing.send() def process_subscription(self, event: Dict[str, Any]) -> None: - assert(self.jabber is not None) + assert self.jabber is not None if event['op'] == 'add': streams = [s['name'].lower() for s in event['subscriptions']] streams = [s for s in streams if s.endswith("/xmpp")] @@ -264,6 +268,7 @@ class ZulipToJabberBot: for stream in streams: self.jabber.leave_muc(stream_to_room(stream)) + def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]: def get_stream_infos(key: str, method: Callable[[], Dict[str, Any]]) -> Any: ret = method() @@ -284,17 +289,21 @@ def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]: rooms.append(stream_to_room(stream)) return rooms + def config_error(msg: str) -> None: sys.stderr.write("%s\n" % (msg,)) sys.exit(2) + if __name__ == '__main__': parser = optparse.OptionParser( epilog='''Most general and Jabber configuration options may also be specified in the zulip configuration file under the jabber_mirror section (exceptions are noted in their help sections). Keys have the same name as options with hyphens replaced with underscores. Zulip configuration options go in the api section, -as normal.'''.replace("\n", " ") +as normal.'''.replace( + "\n", " " + ) ) parser.add_option( '--mode', @@ -305,7 +314,10 @@ as normal.'''.replace("\n", " ") all messages they send on Zulip to Jabber and all private Jabber messages to Zulip. In "public" mode, the mirror uses the credentials for a dedicated mirror user and mirrors messages sent to Jabber rooms to Zulip. Defaults to -"personal"'''.replace("\n", " ")) +"personal"'''.replace( + "\n", " " + ), + ) parser.add_option( '--zulip-email-suffix', default=None, @@ -315,13 +327,19 @@ from JIDs and nicks before sending requests to the Zulip server, and remove the suffix before sending requests to the Jabber server. For example, specifying "+foo" will cause messages that are sent to the "bar" room by nickname "qux" to be mirrored to the "bar/xmpp" stream in Zulip by user "qux+foo@example.com". This -option does not affect login credentials.'''.replace("\n", " ")) - parser.add_option('-d', '--debug', - help='set logging to DEBUG. Can not be set via config file.', - action='store_const', - dest='log_level', - const=logging.DEBUG, - default=logging.INFO) +option does not affect login credentials.'''.replace( + "\n", " " + ), + ) + parser.add_option( + '-d', + '--debug', + help='set logging to DEBUG. Can not be set via config file.', + action='store_const', + dest='log_level', + const=logging.DEBUG, + default=logging.INFO, + ) jabber_group = optparse.OptionGroup(parser, "Jabber configuration") jabber_group.add_option( @@ -329,39 +347,42 @@ option does not affect login credentials.'''.replace("\n", " ")) default=None, action='store', help="Your Jabber JID. If a resource is specified, " - "it will be used as the nickname when joining MUCs. " - "Specifying the nickname is mostly useful if you want " - "to run the public mirror from a regular user instead of " - "from a dedicated account.") - jabber_group.add_option('--jabber-password', - default=None, - action='store', - help="Your Jabber password") - jabber_group.add_option('--conference-domain', - default=None, - action='store', - help="Your Jabber conference domain (E.g. conference.jabber.example.com). " - "If not specifed, \"conference.\" will be prepended to your JID's domain.") - jabber_group.add_option('--no-use-tls', - default=None, - action='store_true') - jabber_group.add_option('--jabber-server-address', - default=None, - action='store', - help="The hostname of your Jabber server. This is only needed if " - "your server is missing SRV records") - jabber_group.add_option('--jabber-server-port', - default='5222', - action='store', - help="The port of your Jabber server. This is only needed if " - "your server is missing SRV records") + "it will be used as the nickname when joining MUCs. " + "Specifying the nickname is mostly useful if you want " + "to run the public mirror from a regular user instead of " + "from a dedicated account.", + ) + jabber_group.add_option( + '--jabber-password', default=None, action='store', help="Your Jabber password" + ) + jabber_group.add_option( + '--conference-domain', + default=None, + action='store', + help="Your Jabber conference domain (E.g. conference.jabber.example.com). " + "If not specifed, \"conference.\" will be prepended to your JID's domain.", + ) + jabber_group.add_option('--no-use-tls', default=None, action='store_true') + jabber_group.add_option( + '--jabber-server-address', + default=None, + action='store', + help="The hostname of your Jabber server. This is only needed if " + "your server is missing SRV records", + ) + jabber_group.add_option( + '--jabber-server-port', + default='5222', + action='store', + help="The port of your Jabber server. This is only needed if " + "your server is missing SRV records", + ) parser.add_option_group(jabber_group) parser.add_option_group(zulip.generate_option_group(parser, "zulip-")) (options, args) = parser.parse_args() - logging.basicConfig(level=options.log_level, - format='%(levelname)-8s %(message)s') + logging.basicConfig(level=options.log_level, format='%(levelname)-8s %(message)s') if options.zulip_config_file is None: default_config_file = zulip.get_default_config_filename() @@ -378,12 +399,16 @@ option does not affect login credentials.'''.replace("\n", " ")) config.readfp(f, config_file) except OSError: pass - for option in ("jid", "jabber_password", "conference_domain", "mode", "zulip_email_suffix", - "jabber_server_address", "jabber_server_port"): - if ( - getattr(options, option) is None - and config.has_option("jabber_mirror", option) - ): + for option in ( + "jid", + "jabber_password", + "conference_domain", + "mode", + "zulip_email_suffix", + "jabber_server_address", + "jabber_server_port", + ): + if getattr(options, option) is None and config.has_option("jabber_mirror", option): setattr(options, option, config.get("jabber_mirror", option)) for option in ("no_use_tls",): @@ -403,10 +428,14 @@ option does not affect login credentials.'''.replace("\n", " ")) config_error("Bad value for --mode: must be one of 'public' or 'personal'") if None in (options.jid, options.jabber_password): - config_error("You must specify your Jabber JID and Jabber password either " - "in the Zulip configuration file or on the commandline") + config_error( + "You must specify your Jabber JID and Jabber password either " + "in the Zulip configuration file or on the commandline" + ) - zulipToJabber = ZulipToJabberBot(zulip.init_from_options(options, "JabberMirror/" + __version__)) + zulipToJabber = ZulipToJabberBot( + zulip.init_from_options(options, "JabberMirror/" + __version__) + ) # This won't work for open realms that don't have a consistent domain options.zulip_domain = zulipToJabber.client.email.partition('@')[-1] @@ -438,8 +467,9 @@ option does not affect login credentials.'''.replace("\n", " ")) try: logging.info("Connecting to Zulip.") - zulipToJabber.client.call_on_each_event(zulipToJabber.process_event, - event_types=event_types) + zulipToJabber.client.call_on_each_event( + zulipToJabber.process_event, event_types=event_types + ) except BaseException: logging.exception("Exception in main loop") xmpp.abort() diff --git a/zulip/integrations/log2zulip/log2zulip b/zulip/integrations/log2zulip/log2zulip index 55d8218..d0b5bcf 100755 --- a/zulip/integrations/log2zulip/log2zulip +++ b/zulip/integrations/log2zulip/log2zulip @@ -14,10 +14,12 @@ import traceback sys.path.append("/home/zulip/deployments/current") try: from scripts.lib.setup_path import setup_path + setup_path() except ImportError: try: import scripts.lib.setup_path_on_import + scripts.lib.setup_path_on_import # Suppress unused import warning except ImportError: pass @@ -31,6 +33,7 @@ import zulip temp_dir = "/var/tmp/" if os.name == "posix" else tempfile.gettempdir() + def mkdir_p(path: str) -> None: # Python doesn't have an analog to `mkdir -p` < Python 3.2. try: @@ -41,14 +44,18 @@ def mkdir_p(path: str) -> None: else: raise + def send_log_zulip(file_name: str, count: int, lines: List[str], extra: str = "") -> None: content = "%s new errors%s:\n```\n%s\n```" % (count, extra, "\n".join(lines)) - zulip_client.send_message({ - "type": "stream", - "to": "logs", - "subject": "%s on %s" % (file_name, platform.node()), - "content": content, - }) + zulip_client.send_message( + { + "type": "stream", + "to": "logs", + "subject": "%s on %s" % (file_name, platform.node()), + "content": content, + } + ) + def process_lines(raw_lines: List[str], file_name: str) -> None: lines = [] @@ -65,6 +72,7 @@ def process_lines(raw_lines: List[str], file_name: str) -> None: else: send_log_zulip(file_name, len(lines), lines) + def process_logs() -> None: data_file_path = os.path.join(temp_dir, "log2zulip.state") mkdir_p(os.path.dirname(data_file_path)) @@ -95,6 +103,7 @@ def process_logs() -> None: new_data[log_file] = file_data open(data_file_path, "w").write(json.dumps(new_data)) + if __name__ == "__main__": parser = zulip.add_default_arguments(argparse.ArgumentParser()) # type: argparse.ArgumentParser parser.add_argument("--control-path", default="/etc/log2zulip.conf") diff --git a/zulip/integrations/nagios/nagios-notify-zulip b/zulip/integrations/nagios/nagios-notify-zulip index e980cf1..105ae1e 100755 --- a/zulip/integrations/nagios/nagios-notify-zulip +++ b/zulip/integrations/nagios/nagios-notify-zulip @@ -17,8 +17,9 @@ for opt in ('type', 'host', 'service', 'state'): parser.add_argument('--' + opt) opts = parser.parse_args() -client = zulip.Client(config_file=opts.config, - client="ZulipNagios/" + VERSION) # type: zulip.Client +client = zulip.Client( + config_file=opts.config, client="ZulipNagios/" + VERSION +) # type: zulip.Client msg = dict(type='stream', to=opts.stream) # type: Dict[str, Any] @@ -47,6 +48,6 @@ msg['content'] = '**%s**: %s is %s' % (opts.type, thing, opts.state) output = (opts.output + '\n' + opts.long_output.replace(r'\n', '\n')).strip() # type: Text if output: # Put any command output in a code block. - msg['content'] += ('\n\n~~~~\n' + output + "\n~~~~\n") + msg['content'] += '\n\n~~~~\n' + output + "\n~~~~\n" client.send_message(msg) diff --git a/zulip/integrations/openshift/post_deploy b/zulip/integrations/openshift/post_deploy index 4f462c6..1f3c73f 100755 --- a/zulip/integrations/openshift/post_deploy +++ b/zulip/integrations/openshift/post_deploy @@ -21,7 +21,9 @@ client = zulip.Client( email=config.ZULIP_USER, site=config.ZULIP_SITE, api_key=config.ZULIP_API_KEY, - client='ZulipOpenShift/' + VERSION) + client='ZulipOpenShift/' + VERSION, +) + def get_deployment_details() -> Dict[str, str]: # "gear deployments" output example: @@ -30,10 +32,13 @@ def get_deployment_details() -> Dict[str, str]: dep = subprocess.check_output(['gear', 'deployments'], universal_newlines=True).splitlines()[1] splits = dep.split(' - ') - return dict(app_name=os.environ['OPENSHIFT_APP_NAME'], - url=os.environ['OPENSHIFT_APP_DNS'], - branch=splits[2], - commit_id=splits[3]) + return dict( + app_name=os.environ['OPENSHIFT_APP_NAME'], + url=os.environ['OPENSHIFT_APP_DNS'], + branch=splits[2], + commit_id=splits[3], + ) + def send_bot_message(deployment: Dict[str, str]) -> None: destination = config.deployment_notice_destination(deployment['branch']) @@ -42,14 +47,17 @@ def send_bot_message(deployment: Dict[str, str]) -> None: return message = config.format_deployment_message(**deployment) - client.send_message({ - 'type': 'stream', - 'to': destination['stream'], - 'subject': destination['subject'], - 'content': message, - }) + client.send_message( + { + 'type': 'stream', + 'to': destination['stream'], + 'subject': destination['subject'], + 'content': message, + } + ) return + deployment = get_deployment_details() send_bot_message(deployment) diff --git a/zulip/integrations/openshift/zulip_openshift_config.py b/zulip/integrations/openshift/zulip_openshift_config.py index 9957a28..d63b230 100755 --- a/zulip/integrations/openshift/zulip_openshift_config.py +++ b/zulip/integrations/openshift/zulip_openshift_config.py @@ -21,12 +21,12 @@ ZULIP_API_KEY = '0123456789abcdef0123456789abcdef' # And similarly for branch "test-post-receive" (for use when testing). def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]: if branch in ['master', 'test-post-receive']: - return dict(stream = 'deployments', - subject = '%s' % (branch,)) + return dict(stream='deployments', subject='%s' % (branch,)) # Return None for cases where you don't want a notice sent return None + # Modify this function to change how deployments are displayed # # It takes the following arguments: @@ -39,9 +39,15 @@ def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]: # * dep_id = deployment id # * dep_time = deployment timestamp def format_deployment_message( - app_name: str = '', url: str = '', branch: str = '', commit_id: str = '', dep_id: str = '', dep_time: str = '') -> str: - return 'Deployed commit `%s` (%s) in [%s](%s)' % ( - commit_id, branch, app_name, url) + app_name: str = '', + url: str = '', + branch: str = '', + commit_id: str = '', + dep_id: str = '', + dep_time: str = '', +) -> str: + return 'Deployed commit `%s` (%s) in [%s](%s)' % (commit_id, branch, app_name, url) + ## If properly installed, the Zulip API should be in your import ## path, but if not, set a custom path below diff --git a/zulip/integrations/perforce/zulip_change-commit.py b/zulip/integrations/perforce/zulip_change-commit.py index 1764217..fed1de4 100755 --- a/zulip/integrations/perforce/zulip_change-commit.py +++ b/zulip/integrations/perforce/zulip_change-commit.py @@ -36,7 +36,8 @@ client = zulip.Client( email=config.ZULIP_USER, site=config.ZULIP_SITE, api_key=config.ZULIP_API_KEY, - client="ZulipPerforce/" + __version__) # type: zulip.Client + client="ZulipPerforce/" + __version__, +) # type: zulip.Client try: changelist = int(sys.argv[1]) # type: int @@ -52,7 +53,9 @@ except ValueError: metadata = git_p4.p4_describe(changelist) # type: Dict[str, str] -destination = config.commit_notice_destination(changeroot, changelist) # type: Optional[Dict[str, str]] +destination = config.commit_notice_destination( + changeroot, changelist +) # type: Optional[Dict[str, str]] if destination is None: # Don't forward the notice anywhere @@ -84,10 +87,8 @@ message = """**{user}** committed revision @{change} to `{path}`. {desc} ``` """.format( - user=metadata["user"], - change=change, - path=changeroot, - desc=metadata["desc"]) # type: str + user=metadata["user"], change=change, path=changeroot, desc=metadata["desc"] +) # type: str message_data = { "type": "stream", diff --git a/zulip/integrations/perforce/zulip_perforce_config.py b/zulip/integrations/perforce/zulip_perforce_config.py index 2e2aa2f..417ac2c 100644 --- a/zulip/integrations/perforce/zulip_perforce_config.py +++ b/zulip/integrations/perforce/zulip_perforce_config.py @@ -37,12 +37,12 @@ def commit_notice_destination(path: Text, changelist: int) -> Optional[Dict[Text directory = dirs[2] if directory not in ["evil-master-plan", "my-super-secret-repository"]: - return dict(stream = "%s-commits" % (directory,), - subject = path) + return dict(stream="%s-commits" % (directory,), subject=path) # Return None for cases where you don't want a notice sent return None + ## If properly installed, the Zulip API should be in your import ## path, but if not, set a custom path below ZULIP_API_PATH: Optional[str] = None diff --git a/zulip/integrations/rss/rss-bot b/zulip/integrations/rss/rss-bot index 0dbd0bb..45dbce3 100755 --- a/zulip/integrations/rss/rss-bot +++ b/zulip/integrations/rss/rss-bot @@ -48,35 +48,48 @@ stream every 5 minutes is: */5 * * * * /usr/local/share/zulip/integrations/rss/rss-bot""" -parser = zulip.add_default_arguments(argparse.ArgumentParser(usage)) # type: argparse.ArgumentParser -parser.add_argument('--stream', - dest='stream', - help='The stream to which to send RSS messages.', - default="rss", - action='store') -parser.add_argument('--data-dir', - dest='data_dir', - help='The directory where feed metadata is stored', - default=os.path.join(RSS_DATA_DIR), - action='store') -parser.add_argument('--feed-file', - dest='feed_file', - help='The file containing a list of RSS feed URLs to follow, one URL per line', - default=os.path.join(RSS_DATA_DIR, "rss-feeds"), - action='store') -parser.add_argument('--unwrap', - dest='unwrap', - action='store_true', - help='Convert word-wrapped paragraphs into single lines', - default=False) -parser.add_argument('--math', - dest='math', - action='store_true', - help='Convert $ to $$ (for KaTeX processing)', - default=False) +parser = zulip.add_default_arguments( + argparse.ArgumentParser(usage) +) # type: argparse.ArgumentParser +parser.add_argument( + '--stream', + dest='stream', + help='The stream to which to send RSS messages.', + default="rss", + action='store', +) +parser.add_argument( + '--data-dir', + dest='data_dir', + help='The directory where feed metadata is stored', + default=os.path.join(RSS_DATA_DIR), + action='store', +) +parser.add_argument( + '--feed-file', + dest='feed_file', + help='The file containing a list of RSS feed URLs to follow, one URL per line', + default=os.path.join(RSS_DATA_DIR, "rss-feeds"), + action='store', +) +parser.add_argument( + '--unwrap', + dest='unwrap', + action='store_true', + help='Convert word-wrapped paragraphs into single lines', + default=False, +) +parser.add_argument( + '--math', + dest='math', + action='store_true', + help='Convert $ to $$ (for KaTeX processing)', + default=False, +) opts = parser.parse_args() # type: Any + def mkdir_p(path: str) -> None: # Python doesn't have an analog to `mkdir -p` < Python 3.2. try: @@ -87,6 +100,7 @@ def mkdir_p(path: str) -> None: else: raise + try: mkdir_p(opts.data_dir) except OSError: @@ -106,11 +120,13 @@ logger = logging.getLogger(__name__) # type: logging.Logger logger.setLevel(logging.DEBUG) logger.addHandler(file_handler) + def log_error_and_exit(error: str) -> None: logger.error(error) logger.error(usage) exit(1) + class MLStripper(HTMLParser): def __init__(self) -> None: super().__init__() @@ -123,57 +139,70 @@ class MLStripper(HTMLParser): def get_data(self) -> str: return ''.join(self.fed) + def strip_tags(html: str) -> str: stripper = MLStripper() stripper.feed(html) return stripper.get_data() + def compute_entry_hash(entry: Dict[str, Any]) -> str: entry_time = entry.get("published", entry.get("updated")) entry_id = entry.get("id", entry.get("link")) return hashlib.md5((entry_id + str(entry_time)).encode()).hexdigest() + def unwrap_text(body: str) -> str: # Replace \n by space if it is preceded and followed by a non-\n. # Example: '\na\nb\nc\n\nd\n' -> '\na b c\n\nd\n' return re.sub('(?<=[^\n])\n(?=[^\n])', ' ', body) + def elide_subject(subject: str) -> str: MAX_TOPIC_LENGTH = 60 if len(subject) > MAX_TOPIC_LENGTH: - subject = subject[:MAX_TOPIC_LENGTH - 3].rstrip() + '...' + subject = subject[: MAX_TOPIC_LENGTH - 3].rstrip() + '...' return subject + def send_zulip(entry: Any, feed_name: str) -> Dict[str, Any]: body = entry.summary # type: str if opts.unwrap: body = unwrap_text(body) - content = "**[%s](%s)**\n%s\n%s" % (entry.title, - entry.link, - strip_tags(body), - entry.link) # type: str + content = "**[%s](%s)**\n%s\n%s" % ( + entry.title, + entry.link, + strip_tags(body), + entry.link, + ) # type: str if opts.math: content = content.replace('$', '$$') - message = {"type": "stream", - "sender": opts.zulip_email, - "to": opts.stream, - "subject": elide_subject(feed_name), - "content": content, - } # type: Dict[str, str] + message = { + "type": "stream", + "sender": opts.zulip_email, + "to": opts.stream, + "subject": elide_subject(feed_name), + "content": content, + } # type: Dict[str, str] return client.send_message(message) + try: with open(opts.feed_file) as f: feed_urls = [feed.strip() for feed in f.readlines()] # type: List[str] except OSError: log_error_and_exit("Unable to read feed file at %s." % (opts.feed_file,)) -client = zulip.Client(email=opts.zulip_email, api_key=opts.zulip_api_key, - config_file=opts.zulip_config_file, - site=opts.zulip_site, client="ZulipRSS/" + VERSION) # type: zulip.Client +client = zulip.Client( + email=opts.zulip_email, + api_key=opts.zulip_api_key, + config_file=opts.zulip_config_file, + site=opts.zulip_site, + client="ZulipRSS/" + VERSION, +) # type: zulip.Client first_message = True # type: bool @@ -182,7 +211,9 @@ for feed_url in feed_urls: try: with open(feed_file) as f: - old_feed_hashes = {line.strip(): True for line in f.readlines()} # type: Dict[str, bool] + old_feed_hashes = { + line.strip(): True for line in f.readlines() + } # type: Dict[str, bool] except OSError: old_feed_hashes = {} @@ -192,8 +223,13 @@ for feed_url in feed_urls: for entry in data.entries: entry_hash = compute_entry_hash(entry) # type: str # An entry has either been published or updated. - entry_time = entry.get("published_parsed", entry.get("updated_parsed")) # type: Tuple[int, int] - if entry_time is not None and (time.time() - calendar.timegm(entry_time)) > OLDNESS_THRESHOLD * 60 * 60 * 24: + entry_time = entry.get( + "published_parsed", entry.get("updated_parsed") + ) # type: Tuple[int, int] + if ( + entry_time is not None + and (time.time() - calendar.timegm(entry_time)) > OLDNESS_THRESHOLD * 60 * 60 * 24 + ): # As a safeguard against misbehaving feeds, don't try to process # entries older than some threshold. continue diff --git a/zulip/integrations/svn/post-commit b/zulip/integrations/svn/post-commit index 31cd196..0025818 100755 --- a/zulip/integrations/svn/post-commit +++ b/zulip/integrations/svn/post-commit @@ -30,7 +30,8 @@ client = zulip.Client( email=config.ZULIP_USER, site=config.ZULIP_SITE, api_key=config.ZULIP_API_KEY, - client="ZulipSVN/" + VERSION) # type: zulip.Client + client="ZulipSVN/" + VERSION, +) # type: zulip.Client svn = pysvn.Client() # type: pysvn.Client path, rev = sys.argv[1:] # type: Tuple[Text, Text] @@ -38,12 +39,12 @@ path, rev = sys.argv[1:] # type: Tuple[Text, Text] # since its a local path, prepend "file://" path = "file://" + path -entry = svn.log(path, revision_end=pysvn.Revision(pysvn.opt_revision_kind.number, rev))[0] # type: Dict[Text, Any] +entry = svn.log(path, revision_end=pysvn.Revision(pysvn.opt_revision_kind.number, rev))[ + 0 +] # type: Dict[Text, Any] message = "**{}** committed revision r{} to `{}`.\n\n> {}".format( - entry['author'], - rev, - path.split('/')[-1], - entry['revprops']['svn:log']) # type: Text + entry['author'], rev, path.split('/')[-1], entry['revprops']['svn:log'] +) # type: Text destination = config.commit_notice_destination(path, rev) # type: Optional[Dict[Text, Text]] diff --git a/zulip/integrations/svn/zulip_svn_config.py b/zulip/integrations/svn/zulip_svn_config.py index 4c9d94d..0479e92 100644 --- a/zulip/integrations/svn/zulip_svn_config.py +++ b/zulip/integrations/svn/zulip_svn_config.py @@ -21,12 +21,12 @@ ZULIP_API_KEY = "0123456789abcdef0123456789abcdef" def commit_notice_destination(path: Text, commit: Text) -> Optional[Dict[Text, Text]]: repo = path.split('/')[-1] if repo not in ["evil-master-plan", "my-super-secret-repository"]: - return dict(stream = "commits", - subject = "%s" % (repo,)) + return dict(stream="commits", subject="%s" % (repo,)) # Return None for cases where you don't want a notice sent return None + ## If properly installed, the Zulip API should be in your import ## path, but if not, set a custom path below ZULIP_API_PATH: Optional[str] = None diff --git a/zulip/integrations/trac/zulip_trac.py b/zulip/integrations/trac/zulip_trac.py index da2021e..202dc3c 100644 --- a/zulip/integrations/trac/zulip_trac.py +++ b/zulip/integrations/trac/zulip_trac.py @@ -33,38 +33,50 @@ client = zulip.Client( email=config.ZULIP_USER, site=config.ZULIP_SITE, api_key=config.ZULIP_API_KEY, - client="ZulipTrac/" + VERSION) + client="ZulipTrac/" + VERSION, +) + def markdown_ticket_url(ticket: Any, heading: str = "ticket") -> str: return "[%s #%s](%s/%s)" % (heading, ticket.id, config.TRAC_BASE_TICKET_URL, ticket.id) + def markdown_block(desc: str) -> str: return "\n\n>" + "\n> ".join(desc.split("\n")) + "\n" + def truncate(string: str, length: int) -> str: if len(string) <= length: return string - return string[:length - 3] + "..." + return string[: length - 3] + "..." + def trac_subject(ticket: Any) -> str: return truncate("#%s: %s" % (ticket.id, ticket.values.get("summary")), 60) + def send_update(ticket: Any, content: str) -> None: - client.send_message({ - "type": "stream", - "to": config.STREAM_FOR_NOTIFICATIONS, - "content": content, - "subject": trac_subject(ticket) - }) + client.send_message( + { + "type": "stream", + "to": config.STREAM_FOR_NOTIFICATIONS, + "content": content, + "subject": trac_subject(ticket), + } + ) + class ZulipPlugin(Component): implements(ITicketChangeListener) def ticket_created(self, ticket: Any) -> None: """Called when a ticket is created.""" - content = "%s created %s in component **%s**, priority **%s**:\n" % \ - (ticket.values.get("reporter"), markdown_ticket_url(ticket), - ticket.values.get("component"), ticket.values.get("priority")) + content = "%s created %s in component **%s**, priority **%s**:\n" % ( + ticket.values.get("reporter"), + markdown_ticket_url(ticket), + ticket.values.get("component"), + ticket.values.get("priority"), + ) # Include the full subject if it will be truncated if len(ticket.values.get("summary")) > 60: content += "**%s**\n" % (ticket.values.get("summary"),) @@ -72,7 +84,9 @@ class ZulipPlugin(Component): content += "%s" % (markdown_block(ticket.values.get("description")),) send_update(ticket, content) - def ticket_changed(self, ticket: Any, comment: str, author: str, old_values: Dict[str, Any]) -> None: + def ticket_changed( + self, ticket: Any, comment: str, author: str, old_values: Dict[str, Any] + ) -> None: """Called when a ticket is modified. `old_values` is a dictionary containing the previous values of the @@ -92,15 +106,19 @@ class ZulipPlugin(Component): field_changes = [] for key, value in old_values.items(): if key == "description": - content += '- Changed %s from %s\n\nto %s' % (key, markdown_block(value), - markdown_block(ticket.values.get(key))) + content += '- Changed %s from %s\n\nto %s' % ( + key, + markdown_block(value), + markdown_block(ticket.values.get(key)), + ) elif old_values.get(key) == "": field_changes.append('%s: => **%s**' % (key, ticket.values.get(key))) elif ticket.values.get(key) == "": field_changes.append('%s: **%s** => ""' % (key, old_values.get(key))) else: - field_changes.append('%s: **%s** => **%s**' % (key, old_values.get(key), - ticket.values.get(key))) + field_changes.append( + '%s: **%s** => **%s**' % (key, old_values.get(key), ticket.values.get(key)) + ) content += ", ".join(field_changes) send_update(ticket, content) diff --git a/zulip/integrations/trello/zulip_trello.py b/zulip/integrations/trello/zulip_trello.py index 906210e..cd335a0 100755 --- a/zulip/integrations/trello/zulip_trello.py +++ b/zulip/integrations/trello/zulip_trello.py @@ -13,6 +13,7 @@ except ImportError: print("http://docs.python-requests.org/en/master/user/install/") sys.exit(1) + def get_model_id(options): """get_model_id @@ -24,19 +25,14 @@ def get_model_id(options): """ - trello_api_url = 'https://api.trello.com/1/board/{}'.format( - options.trello_board_id - ) + trello_api_url = 'https://api.trello.com/1/board/{}'.format(options.trello_board_id) params = { 'key': options.trello_api_key, 'token': options.trello_token, } - trello_response = requests.get( - trello_api_url, - params=params - ) + trello_response = requests.get(trello_api_url, params=params) if trello_response.status_code != 200: print('Error: Can\'t get the idModel. Please check the configuration') @@ -68,13 +64,10 @@ def get_webhook_id(options, id_model): options.trello_board_name, ), 'callbackURL': options.zulip_webhook_url, - 'idModel': id_model + 'idModel': id_model, } - trello_response = requests.post( - trello_api_url, - data=data - ) + trello_response = requests.post(trello_api_url, data=data) if trello_response.status_code != 200: print('Error: Can\'t create the Webhook:', trello_response.text) @@ -84,6 +77,7 @@ def get_webhook_id(options, id_model): return webhook_info_json['id'] + def create_webhook(options): """create_webhook @@ -106,8 +100,12 @@ def create_webhook(options): if id_webhook: print('Success! The webhook ID is', id_webhook) - print('Success! The webhook for the {} Trello board was successfully created.'.format( - options.trello_board_name)) + print( + 'Success! The webhook for the {} Trello board was successfully created.'.format( + options.trello_board_name + ) + ) + def main(): description = """ @@ -120,28 +118,36 @@ at . """ parser = argparse.ArgumentParser(description=description) - parser.add_argument('--trello-board-name', - required=True, - help='The Trello board name.') - parser.add_argument('--trello-board-id', - required=True, - help=('The Trello board short ID. Can usually be found ' - 'in the URL of the Trello board.')) - parser.add_argument('--trello-api-key', - required=True, - help=('Visit https://trello.com/1/appkey/generate to generate ' - 'an APPLICATION_KEY (need to be logged into Trello).')) - parser.add_argument('--trello-token', - required=True, - help=('Visit https://trello.com/1/appkey/generate and under ' - '`Developer API Keys`, click on `Token` and generate ' - 'a Trello access token.')) - parser.add_argument('--zulip-webhook-url', - required=True, - help='The webhook URL that Trello will query.') + parser.add_argument('--trello-board-name', required=True, help='The Trello board name.') + parser.add_argument( + '--trello-board-id', + required=True, + help=('The Trello board short ID. Can usually be found ' 'in the URL of the Trello board.'), + ) + parser.add_argument( + '--trello-api-key', + required=True, + help=( + 'Visit https://trello.com/1/appkey/generate to generate ' + 'an APPLICATION_KEY (need to be logged into Trello).' + ), + ) + parser.add_argument( + '--trello-token', + required=True, + help=( + 'Visit https://trello.com/1/appkey/generate and under ' + '`Developer API Keys`, click on `Token` and generate ' + 'a Trello access token.' + ), + ) + parser.add_argument( + '--zulip-webhook-url', required=True, help='The webhook URL that Trello will query.' + ) options = parser.parse_args() create_webhook(options) + if __name__ == '__main__': main() diff --git a/zulip/integrations/twitter/twitter-bot b/zulip/integrations/twitter/twitter-bot index c624fe1..7cc13a4 100755 --- a/zulip/integrations/twitter/twitter-bot +++ b/zulip/integrations/twitter/twitter-bot @@ -67,37 +67,34 @@ Make sure to go the application you created and click "create my access token" as well. Fill in the values displayed. """ + def write_config(config: ConfigParser, configfile_path: str) -> None: with open(configfile_path, 'w') as configfile: config.write(configfile) + parser = zulip.add_default_arguments(argparse.ArgumentParser("Fetch tweets from Twitter.")) -parser.add_argument('--instructions', - action='store_true', - help='Show instructions for the twitter bot setup and exit' - ) -parser.add_argument('--limit-tweets', - default=15, - type=int, - help='Maximum number of tweets to send at once') -parser.add_argument('--search', - dest='search_terms', - help='Terms to search on', - action='store') -parser.add_argument('--stream', - dest='stream', - help='The stream to which to send tweets', - default="twitter", - action='store') -parser.add_argument('--twitter-name', - dest='twitter_name', - help='Twitter username to poll new tweets from"') -parser.add_argument('--excluded-terms', - dest='excluded_terms', - help='Terms to exclude tweets on') -parser.add_argument('--excluded-users', - dest='excluded_users', - help='Users to exclude tweets on') +parser.add_argument( + '--instructions', + action='store_true', + help='Show instructions for the twitter bot setup and exit', +) +parser.add_argument( + '--limit-tweets', default=15, type=int, help='Maximum number of tweets to send at once' +) +parser.add_argument('--search', dest='search_terms', help='Terms to search on', action='store') +parser.add_argument( + '--stream', + dest='stream', + help='The stream to which to send tweets', + default="twitter", + action='store', +) +parser.add_argument( + '--twitter-name', dest='twitter_name', help='Twitter username to poll new tweets from"' +) +parser.add_argument('--excluded-terms', dest='excluded_terms', help='Terms to exclude tweets on') +parser.add_argument('--excluded-users', dest='excluded_users', help='Users to exclude tweets on') opts = parser.parse_args() @@ -150,18 +147,22 @@ try: except ImportError: parser.error("Please install python-twitter") -api = twitter.Api(consumer_key=consumer_key, - consumer_secret=consumer_secret, - access_token_key=access_token_key, - access_token_secret=access_token_secret) +api = twitter.Api( + consumer_key=consumer_key, + consumer_secret=consumer_secret, + access_token_key=access_token_key, + access_token_secret=access_token_secret, +) user = api.VerifyCredentials() if not user.id: - print("Unable to log in to twitter with supplied credentials. Please double-check and try again") + print( + "Unable to log in to twitter with supplied credentials. Please double-check and try again" + ) sys.exit(1) -client = zulip.init_from_options(opts, client=client_type+VERSION) +client = zulip.init_from_options(opts, client=client_type + VERSION) if opts.search_terms: search_query = " OR ".join(opts.search_terms.split(",")) @@ -190,7 +191,7 @@ if opts.excluded_users: else: excluded_users = [] -for status in statuses[::-1][:opts.limit_tweets]: +for status in statuses[::-1][: opts.limit_tweets]: # Check if the tweet is from an excluded user exclude = False for user in excluded_users: @@ -237,12 +238,7 @@ for status in statuses[::-1][:opts.limit_tweets]: elif opts.twitter_name: subject = composed - message = { - "type": "stream", - "to": [opts.stream], - "subject": subject, - "content": url - } + message = {"type": "stream", "to": [opts.stream], "subject": subject, "content": url} ret = client.send_message(message) diff --git a/zulip/integrations/zephyr/check-mirroring b/zulip/integrations/zephyr/check-mirroring index 01810c1..1af229d 100755 --- a/zulip/integrations/zephyr/check-mirroring +++ b/zulip/integrations/zephyr/check-mirroring @@ -13,36 +13,25 @@ import zephyr import zulip parser = optparse.OptionParser() -parser.add_option('--verbose', - dest='verbose', - default=False, - action='store_true') -parser.add_option('--site', - dest='site', - default=None, - action='store') -parser.add_option('--sharded', - default=False, - action='store_true') +parser.add_option('--verbose', dest='verbose', default=False, action='store_true') +parser.add_option('--site', dest='site', default=None, action='store') +parser.add_option('--sharded', default=False, action='store_true') (options, args) = parser.parse_args() mit_user = 'tabbott/extra@ATHENA.MIT.EDU' -zulip_client = zulip.Client( - verbose=True, - client="ZulipMonitoring/0.1", - site=options.site) +zulip_client = zulip.Client(verbose=True, client="ZulipMonitoring/0.1", site=options.site) # Configure logging -log_file = "/var/log/zulip/check-mirroring-log" -log_format = "%(asctime)s: %(message)s" +log_file = "/var/log/zulip/check-mirroring-log" +log_format = "%(asctime)s: %(message)s" logging.basicConfig(format=log_format) -formatter = logging.Formatter(log_format) +formatter = logging.Formatter(log_format) file_handler = logging.FileHandler(log_file) file_handler.setFormatter(formatter) -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) logger.addHandler(file_handler) @@ -75,13 +64,14 @@ if options.sharded: for (stream, test) in test_streams: if stream == "message": continue - assert(hashlib.sha1(stream.encode("utf-8")).hexdigest().startswith(test)) + assert hashlib.sha1(stream.encode("utf-8")).hexdigest().startswith(test) else: test_streams = [ ("message", "p"), ("tabbott-nagios-test", "a"), ] + def print_status_and_exit(status: int) -> None: # The output of this script is used by Nagios. Various outputs, @@ -91,6 +81,7 @@ def print_status_and_exit(status: int) -> None: print(status) sys.exit(status) + def send_zulip(message: Dict[str, str]) -> None: result = zulip_client.send_message(message) if result["result"] != "success": @@ -99,11 +90,16 @@ def send_zulip(message: Dict[str, str]) -> None: logger.error(str(result)) print_status_and_exit(1) + # Returns True if and only if we "Detected server failure" sending the zephyr. def send_zephyr(zwrite_args: List[str], content: str) -> bool: - p = subprocess.Popen(zwrite_args, stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - universal_newlines=True) + p = subprocess.Popen( + zwrite_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) stdout, stderr = p.communicate(input=content) if p.returncode != 0: if "Detected server failure while receiving acknowledgement for" in stdout: @@ -116,6 +112,7 @@ def send_zephyr(zwrite_args: List[str], content: str) -> bool: print_status_and_exit(1) return False + # Subscribe to Zulip try: res = zulip_client.register(event_types=["message"]) @@ -164,6 +161,8 @@ if not actually_subscribed: # Prepare keys zhkeys = {} # type: Dict[str, Tuple[str, str]] hzkeys = {} # type: Dict[str, Tuple[str, str]] + + def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str: bits = str(random.getrandbits(32)) while bits in key_dict: @@ -171,10 +170,12 @@ def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str: bits = str(random.getrandbits(32)) return bits + def gen_keys(key_dict: Dict[str, Tuple[str, str]]) -> None: for (stream, test) in test_streams: key_dict[gen_key(key_dict)] = (stream, test) + gen_keys(zhkeys) gen_keys(hzkeys) @@ -196,6 +197,7 @@ def receive_zephyrs() -> None: continue notices.append(notice) + logger.info("Starting sending messages!") # Send zephyrs zsig = "Timothy Good Abbott" @@ -212,12 +214,15 @@ for key, (stream, test) in zhkeys.items(): zhkeys[new_key] = value server_failure_again = send_zephyr(zwrite_args, str(new_key)) if server_failure_again: - logging.error("Zephyr server failure twice in a row on keys %s and %s! Aborting." % - (key, new_key)) + logging.error( + "Zephyr server failure twice in a row on keys %s and %s! Aborting." + % (key, new_key) + ) print_status_and_exit(1) else: - logging.warning("Replaced key %s with %s due to Zephyr server failure." % - (key, new_key)) + logging.warning( + "Replaced key %s with %s due to Zephyr server failure." % (key, new_key) + ) receive_zephyrs() receive_zephyrs() @@ -226,18 +231,22 @@ logger.info("Sent Zephyr messages!") # Send Zulips for key, (stream, test) in hzkeys.items(): if stream == "message": - send_zulip({ - "type": "private", - "content": str(key), - "to": zulip_client.email, - }) + send_zulip( + { + "type": "private", + "content": str(key), + "to": zulip_client.email, + } + ) else: - send_zulip({ - "type": "stream", - "subject": "test", - "content": str(key), - "to": stream, - }) + send_zulip( + { + "type": "stream", + "subject": "test", + "content": str(key), + "to": stream, + } + ) receive_zephyrs() logger.info("Sent Zulip messages!") @@ -265,6 +274,8 @@ receive_zephyrs() logger.info("Finished receiving Zephyr messages!") all_keys = set(list(zhkeys.keys()) + list(hzkeys.keys())) + + def process_keys(content_list: List[str]) -> Tuple[Dict[str, int], Set[str], Set[str], bool, bool]: # Start by filtering out any keys that might have come from @@ -281,6 +292,7 @@ def process_keys(content_list: List[str]) -> Tuple[Dict[str, int], Set[str], Set success = all(val == 1 for val in key_counts.values()) return key_counts, z_missing, h_missing, duplicates, success + # The h_foo variables are about the messages we _received_ in Zulip # The z_foo variables are about the messages we _received_ in Zephyr h_contents = [message["content"] for message in messages] @@ -302,12 +314,16 @@ for key in all_keys: continue if key in zhkeys: (stream, test) = zhkeys[key] - logger.warning("%10s: z got %s, h got %s. Sent via Zephyr(%s): class %s" % - (key, z_key_counts[key], h_key_counts[key], test, stream)) + logger.warning( + "%10s: z got %s, h got %s. Sent via Zephyr(%s): class %s" + % (key, z_key_counts[key], h_key_counts[key], test, stream) + ) if key in hzkeys: (stream, test) = hzkeys[key] - logger.warning("%10s: z got %s. h got %s. Sent via Zulip(%s): class %s" % - (key, z_key_counts[key], h_key_counts[key], test, stream)) + logger.warning( + "%10s: z got %s. h got %s. Sent via Zulip(%s): class %s" + % (key, z_key_counts[key], h_key_counts[key], test, stream) + ) logger.error("") logger.error("Summary of specific problems:") @@ -322,10 +338,14 @@ if z_duplicates: if z_missing_z: logger.error("zephyr: Didn't receive all the Zephyrs we sent on the Zephyr end!") - logger.error("zephyr: This is probably an issue with check-mirroring sending or receiving Zephyrs.") + logger.error( + "zephyr: This is probably an issue with check-mirroring sending or receiving Zephyrs." + ) if h_missing_h: logger.error("zulip: Didn't receive all the Zulips we sent on the Zulip end!") - logger.error("zulip: This is probably an issue with check-mirroring sending or receiving Zulips.") + logger.error( + "zulip: This is probably an issue with check-mirroring sending or receiving Zulips." + ) if z_missing_h: logger.error("zephyr: Didn't receive all the Zulips we sent on the Zephyr end!") if z_missing_h == h_missing_h: diff --git a/zulip/integrations/zephyr/process_ccache b/zulip/integrations/zephyr/process_ccache index c0fe5aa..9ebf1cc 100755 --- a/zulip/integrations/zephyr/process_ccache +++ b/zulip/integrations/zephyr/process_ccache @@ -27,7 +27,9 @@ session_path = "/home/zulip/zephyr_sessions/%s" % (program_name,) try: if "--forward-mail-zephyrs" in open(supervisor_path).read(): - template_data = template_data.replace("--use-sessions", "--use-sessions --forward-mail-zephyrs") + template_data = template_data.replace( + "--use-sessions", "--use-sessions --forward-mail-zephyrs" + ) except Exception: pass open(supervisor_path, "w").write(template_data.replace("USERNAME", short_user)) diff --git a/zulip/integrations/zephyr/sync-public-streams b/zulip/integrations/zephyr/sync-public-streams index f6b8898..a11fcfb 100755 --- a/zulip/integrations/zephyr/sync-public-streams +++ b/zulip/integrations/zephyr/sync-public-streams @@ -17,9 +17,22 @@ def write_public_streams() -> None: # Zephyr class names are canonicalized by first applying NFKC # normalization and then lower-casing server-side canonical_cls = unicodedata.normalize("NFKC", stream_name).lower() - if canonical_cls in ['security', 'login', 'network', 'ops', 'user_locate', - 'mit', 'moof', 'wsmonitor', 'wg_ctl', 'winlogger', - 'hm_ctl', 'hm_stat', 'zephyr_admin', 'zephyr_ctl']: + if canonical_cls in [ + 'security', + 'login', + 'network', + 'ops', + 'user_locate', + 'mit', + 'moof', + 'wsmonitor', + 'wg_ctl', + 'winlogger', + 'hm_ctl', + 'hm_stat', + 'zephyr_admin', + 'zephyr_ctl', + ]: # These zephyr classes cannot be subscribed to by us, due # to MIT's Zephyr access control settings continue @@ -30,6 +43,7 @@ def write_public_streams() -> None: f.write(json.dumps(list(public_streams)) + "\n") os.rename("/home/zulip/public_streams.tmp", "/home/zulip/public_streams") + if __name__ == "__main__": log_file = "/home/zulip/sync_public_streams.log" logger = logging.getLogger(__name__) @@ -83,9 +97,7 @@ if __name__ == "__main__": last_event_id = max(last_event_id, event["id"]) if event["type"] == "stream": if event["op"] == "create": - stream_names.update( - stream["name"] for stream in event["streams"] - ) + stream_names.update(stream["name"] for stream in event["streams"]) write_public_streams() elif event["op"] == "delete": stream_names.difference_update( diff --git a/zulip/integrations/zephyr/zephyr_mirror.py b/zulip/integrations/zephyr/zephyr_mirror.py index aecf3c0..38a2c7d 100755 --- a/zulip/integrations/zephyr/zephyr_mirror.py +++ b/zulip/integrations/zephyr/zephyr_mirror.py @@ -19,6 +19,7 @@ def die(signal: int, frame: FrameType) -> None: # We actually want to exit, so run os._exit (so as not to be caught and restarted) os._exit(1) + signal.signal(signal.SIGINT, die) from zulip import RandomExponentialBackoff @@ -36,12 +37,14 @@ if options.forward_class_messages and not options.noshard: if options.on_startup_command is not None: subprocess.call([options.on_startup_command]) from zerver.lib.parallel import run_parallel + print("Starting parallel zephyr class mirroring bot") jobs = list("0123456789abcdef") def run_job(shard: str) -> int: subprocess.call(args + ["--shard=%s" % (shard,)]) return 0 + for (status, job) in run_parallel(run_job, jobs, threads=16): print("A mirroring shard died!") sys.exit(0) diff --git a/zulip/integrations/zephyr/zephyr_mirror_backend.py b/zulip/integrations/zephyr/zephyr_mirror_backend.py index 6544991..a06a56c 100755 --- a/zulip/integrations/zephyr/zephyr_mirror_backend.py +++ b/zulip/integrations/zephyr/zephyr_mirror_backend.py @@ -22,12 +22,16 @@ from zulip import RandomExponentialBackoff DEFAULT_SITE = "https://api.zulip.com" + class States: Startup, ZulipToZephyr, ZephyrToZulip, ChildSending = list(range(4)) + + CURRENT_STATE = States.Startup logger: logging.Logger + def to_zulip_username(zephyr_username: str) -> str: if "@" in zephyr_username: (user, realm) = zephyr_username.split("@") @@ -40,6 +44,7 @@ def to_zulip_username(zephyr_username: str) -> str: return user.lower() + "@mit.edu" return user.lower() + "|" + realm.upper() + "@mit.edu" + def to_zephyr_username(zulip_username: str) -> str: (user, realm) = zulip_username.split("@") if "|" not in user: @@ -52,6 +57,7 @@ def to_zephyr_username(zulip_username: str) -> str: raise Exception("Could not parse Zephyr realm for cross-realm user %s" % (zulip_username,)) return match_user.group(1).lower() + "@" + match_user.group(2).upper() + # Checks whether the pair of adjacent lines would have been # linewrapped together, had they been intended to be parts of the same # paragraph. Our check is whether if you move the first word on the @@ -70,6 +76,7 @@ def different_paragraph(line: str, next_line: str) -> bool: or len(line) < len(words[0]) ) + # Linewrapping algorithm based on: # http://gcbenison.wordpress.com/2011/07/03/a-program-to-intelligently-remove-carriage-returns-so-you-can-paste-text-without-having-it-look-awful/ #ignorelongline def unwrap_lines(body: str) -> str: @@ -78,9 +85,8 @@ def unwrap_lines(body: str) -> str: previous_line = lines[0] for line in lines[1:]: line = line.rstrip() - if ( - re.match(r'^\W', line, flags=re.UNICODE) - and re.match(r'^\W', previous_line, flags=re.UNICODE) + if re.match(r'^\W', line, flags=re.UNICODE) and re.match( + r'^\W', previous_line, flags=re.UNICODE ): result += previous_line + "\n" elif ( @@ -99,6 +105,7 @@ def unwrap_lines(body: str) -> str: result += previous_line return result + class ZephyrDict(TypedDict, total=False): type: Literal["private", "stream"] time: str @@ -109,6 +116,7 @@ class ZephyrDict(TypedDict, total=False): content: str zsig: str + def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]: message: Dict[str, Any] message = {} @@ -142,15 +150,20 @@ def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]: return zulip_client.send_message(message) + def send_error_zulip(error_msg: str) -> None: - message = {"type": "private", - "sender": zulip_account_email, - "to": zulip_account_email, - "content": error_msg, - } + message = { + "type": "private", + "sender": zulip_account_email, + "to": zulip_account_email, + "content": error_msg, + } zulip_client.send_message(message) + current_zephyr_subs = set() + + def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None: try: zephyr._z.subAll(subs) @@ -186,6 +199,7 @@ def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None: else: current_zephyr_subs.add(cls) + def update_subscriptions() -> None: try: f = open(options.stream_file_path) @@ -198,10 +212,9 @@ def update_subscriptions() -> None: classes_to_subscribe = set() for stream in public_streams: zephyr_class = stream - if ( - options.shard is not None - and not hashlib.sha1(zephyr_class.encode("utf-8")).hexdigest().startswith(options.shard) - ): + if options.shard is not None and not hashlib.sha1( + zephyr_class.encode("utf-8") + ).hexdigest().startswith(options.shard): # This stream is being handled by a different zephyr_mirror job. continue if zephyr_class in current_zephyr_subs: @@ -211,6 +224,7 @@ def update_subscriptions() -> None: if len(classes_to_subscribe) > 0: zephyr_bulk_subscribe(list(classes_to_subscribe)) + def maybe_kill_child() -> None: try: if child_pid is not None: @@ -219,10 +233,14 @@ def maybe_kill_child() -> None: # We don't care if the child process no longer exists, so just log the error logger.exception("") + def maybe_restart_mirroring_script() -> None: - if os.stat(os.path.join(options.stamp_path, "stamps", "restart_stamp")).st_mtime > start_time or ( + if os.stat( + os.path.join(options.stamp_path, "stamps", "restart_stamp") + ).st_mtime > start_time or ( (options.user == "tabbott" or options.user == "tabbott/extra") - and os.stat(os.path.join(options.stamp_path, "stamps", "tabbott_stamp")).st_mtime > start_time + and os.stat(os.path.join(options.stamp_path, "stamps", "tabbott_stamp")).st_mtime + > start_time ): logger.warning("") logger.warning("zephyr mirroring script has been updated; restarting...") @@ -244,6 +262,7 @@ def maybe_restart_mirroring_script() -> None: backoff.fail() raise Exception("Failed to reload too many times, aborting!") + def process_loop(log: Optional[IO[str]]) -> NoReturn: restart_check_count = 0 last_check_time = time.time() @@ -287,6 +306,7 @@ def process_loop(log: Optional[IO[str]]) -> NoReturn: except Exception: logger.exception("Error updating subscriptions from Zulip:") + def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]: try: (zsig, body) = zephyr_data.split("\x00", 1) @@ -298,13 +318,19 @@ def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]: fields = body.split('\x00') if len(fields) == 5: body = 'New transaction [%s] entered in %s\nFrom: %s (%s)\nSubject: %s' % ( - fields[0], fields[1], fields[2], fields[4], fields[3]) + fields[0], + fields[1], + fields[2], + fields[4], + fields[3], + ) except ValueError: (zsig, body) = ("", zephyr_data) # Clean body of any null characters, since they're invalid in our protocol. body = body.replace('\x00', '') return (zsig, body) + def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]: try: crypt_table = open(os.path.join(os.environ["HOME"], ".crypt-table")) @@ -315,17 +341,23 @@ def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]: if line.strip() == "": # Ignore blank lines continue - match = re.match(r"^crypt-(?P\S+):\s+((?P(AES|DES)):\s+)?(?P\S+)$", line) + match = re.match( + r"^crypt-(?P\S+):\s+((?P(AES|DES)):\s+)?(?P\S+)$", line + ) if match is None: # Malformed crypt_table line logger.debug("Invalid crypt_table line!") continue groups = match.groupdict() - if groups['class'].lower() == zephyr_class and 'keypath' in groups and \ - groups.get("algorithm") == "AES": + if ( + groups['class'].lower() == zephyr_class + and 'keypath' in groups + and groups.get("algorithm") == "AES" + ): return groups["keypath"] return None + def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str: keypath = parse_crypt_table(zephyr_class, instance) if keypath is None: @@ -337,27 +369,32 @@ def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str: signal.signal(signal.SIGCHLD, signal.SIG_DFL) # decrypt the message! - p = subprocess.Popen(["gpg", - "--decrypt", - "--no-options", - "--no-default-keyring", - "--keyring=/dev/null", - "--secret-keyring=/dev/null", - "--batch", - "--quiet", - "--no-use-agent", - "--passphrase-file", - keypath], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - errors="replace") + p = subprocess.Popen( + [ + "gpg", + "--decrypt", + "--no-options", + "--no-default-keyring", + "--keyring=/dev/null", + "--secret-keyring=/dev/null", + "--batch", + "--quiet", + "--no-use-agent", + "--passphrase-file", + keypath, + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + errors="replace", + ) decrypted, _ = p.communicate(input=body) # Restore our ignoring signals signal.signal(signal.SIGCHLD, signal.SIG_IGN) return decrypted + def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None: assert notice.sender is not None (zsig, body) = parse_zephyr_body(notice.message, notice.format) @@ -382,8 +419,7 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None: if is_personal and not options.forward_personals: return if (zephyr_class not in current_zephyr_subs) and not is_personal: - logger.debug("Skipping ... %s/%s/%s" % - (zephyr_class, notice.instance, is_personal)) + logger.debug("Skipping ... %s/%s/%s" % (zephyr_class, notice.instance, is_personal)) return if notice.format.startswith("Zephyr error: See") or notice.format.endswith("@(@color(blue))"): logger.debug("Skipping message we got from Zulip!") @@ -401,20 +437,27 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None: if body.startswith("CC:"): is_huddle = True # Map "CC: user1 user2" => "user1@mit.edu, user2@mit.edu" - huddle_recipients = [to_zulip_username(x.strip()) for x in - body.split("\n")[0][4:].split()] + huddle_recipients = [ + to_zulip_username(x.strip()) for x in body.split("\n")[0][4:].split() + ] if notice.sender not in huddle_recipients: huddle_recipients.append(to_zulip_username(notice.sender)) body = body.split("\n", 1)[1] - if options.forward_class_messages and notice.opcode is not None and notice.opcode.lower() == "crypt": + if ( + options.forward_class_messages + and notice.opcode is not None + and notice.opcode.lower() == "crypt" + ): body = decrypt_zephyr(zephyr_class, notice.instance.lower(), body) zeph: ZephyrDict - zeph = {'time': str(notice.time), - 'sender': notice.sender, - 'zsig': zsig, # logged here but not used by app - 'content': body} + zeph = { + 'time': str(notice.time), + 'sender': notice.sender, + 'zsig': zsig, # logged here but not used by app + 'content': body, + } if is_huddle: zeph['type'] = 'private' zeph['recipient'] = huddle_recipients @@ -442,8 +485,9 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None: heading = "" zeph["content"] = heading + zeph["content"] - logger.info("Received a message on %s/%s from %s..." % - (zephyr_class, notice.instance, notice.sender)) + logger.info( + "Received a message on %s/%s from %s..." % (zephyr_class, notice.instance, notice.sender) + ) if log is not None: log.write(json.dumps(zeph) + '\n') log.flush() @@ -461,11 +505,13 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None: finally: os._exit(0) + def quit_failed_initialization(message: str) -> str: logger.error(message) maybe_kill_child() sys.exit(1) + def zephyr_init_autoretry() -> None: backoff = zulip.RandomExponentialBackoff() while backoff.keep_going(): @@ -481,6 +527,7 @@ def zephyr_init_autoretry() -> None: quit_failed_initialization("Could not initialize Zephyr library, quitting!") + def zephyr_load_session_autoretry(session_path: str) -> None: backoff = zulip.RandomExponentialBackoff() while backoff.keep_going(): @@ -497,6 +544,7 @@ def zephyr_load_session_autoretry(session_path: str) -> None: quit_failed_initialization("Could not load saved Zephyr session, quitting!") + def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None: backoff = zulip.RandomExponentialBackoff() while backoff.keep_going(): @@ -512,6 +560,7 @@ def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None: quit_failed_initialization("Could not subscribe to personals, quitting!") + def zephyr_to_zulip(options: optparse.Values) -> None: if options.use_sessions and os.path.exists(options.session_path): logger.info("Loading old session") @@ -542,9 +591,10 @@ def zephyr_to_zulip(options: optparse.Values) -> None: zeph["stream"] = zeph["class"] if "instance" in zeph: zeph["subject"] = zeph["instance"] - logger.info("sending saved message to %s from %s..." % - (zeph.get('stream', zeph.get('recipient')), - zeph['sender'])) + logger.info( + "sending saved message to %s from %s..." + % (zeph.get('stream', zeph.get('recipient')), zeph['sender']) + ) send_zulip(zeph) except Exception: logger.exception("Could not send saved zephyr:") @@ -558,55 +608,75 @@ def zephyr_to_zulip(options: optparse.Values) -> None: else: process_loop(None) + def send_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]: - p = subprocess.Popen(zwrite_args, stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - universal_newlines=True) + p = subprocess.Popen( + zwrite_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) stdout, stderr = p.communicate(input=content) if p.returncode: - logger.error("zwrite command '%s' failed with return code %d:" % ( - " ".join(zwrite_args), p.returncode,)) + logger.error( + "zwrite command '%s' failed with return code %d:" + % ( + " ".join(zwrite_args), + p.returncode, + ) + ) if stdout: logger.info("stdout: " + stdout) elif stderr: - logger.warning("zwrite command '%s' printed the following warning:" % ( - " ".join(zwrite_args),)) + logger.warning( + "zwrite command '%s' printed the following warning:" % (" ".join(zwrite_args),) + ) if stderr: logger.warning("stderr: " + stderr) return (p.returncode, stderr) + def send_authed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]: return send_zephyr(zwrite_args, content) + def send_unauthed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]: return send_zephyr(zwrite_args + ["-d"], content) + def zcrypt_encrypt_content(zephyr_class: str, instance: str, content: str) -> Optional[str]: keypath = parse_crypt_table(zephyr_class, instance) if keypath is None: return None # encrypt the message! - p = subprocess.Popen(["gpg", - "--symmetric", - "--no-options", - "--no-default-keyring", - "--keyring=/dev/null", - "--secret-keyring=/dev/null", - "--batch", - "--quiet", - "--no-use-agent", - "--armor", - "--cipher-algo", "AES", - "--passphrase-file", - keypath], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True) + p = subprocess.Popen( + [ + "gpg", + "--symmetric", + "--no-options", + "--no-default-keyring", + "--keyring=/dev/null", + "--secret-keyring=/dev/null", + "--batch", + "--quiet", + "--no-use-agent", + "--armor", + "--cipher-algo", + "AES", + "--passphrase-file", + keypath, + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) encrypted, _ = p.communicate(input=content) return encrypted + def forward_to_zephyr(message: Dict[str, Any]) -> None: # 'Any' can be of any type of text support_heading = "Hi there! This is an automated message from Zulip." @@ -614,12 +684,20 @@ def forward_to_zephyr(message: Dict[str, Any]) -> None: Feedback button or at support@zulip.com.""" wrapper = textwrap.TextWrapper(break_long_words=False, break_on_hyphens=False) - wrapped_content = "\n".join("\n".join(wrapper.wrap(line)) - for line in message["content"].replace("@", "@@").split("\n")) + wrapped_content = "\n".join( + "\n".join(wrapper.wrap(line)) for line in message["content"].replace("@", "@@").split("\n") + ) - zwrite_args = ["zwrite", "-n", "-s", message["sender_full_name"], - "-F", "Zephyr error: See http://zephyr.1ts.org/wiki/df", - "-x", "UTF-8"] + zwrite_args = [ + "zwrite", + "-n", + "-s", + message["sender_full_name"], + "-F", + "Zephyr error: See http://zephyr.1ts.org/wiki/df", + "-x", + "UTF-8", + ] # Hack to make ctl's fake username setup work :) if message['type'] == "stream" and zulip_account_email == "ctl@mit.edu": @@ -634,9 +712,8 @@ Feedback button or at support@zulip.com.""" # Forward messages sent to '(instance "WHITESPACE")' back to the # appropriate WHITESPACE instance for bidirectional mirroring instance = match_whitespace_instance.group(1) - elif ( - instance == "instance %s" % (zephyr_class,) - or instance == "test instance %s" % (zephyr_class,) + elif instance == "instance %s" % (zephyr_class,) or instance == "test instance %s" % ( + zephyr_class, ): # Forward messages to e.g. -c -i white-magic back from the # place we forward them to @@ -663,15 +740,18 @@ Feedback button or at support@zulip.com.""" zwrite_args.extend(["-C"]) # We drop the @ATHENA.MIT.EDU here because otherwise the # "CC: user1 user2 ..." output will be unnecessarily verbose. - recipients = [to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "") - for user in message["display_recipient"]] + recipients = [ + to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "") + for user in message["display_recipient"] + ] logger.info("Forwarding message to %s" % (recipients,)) zwrite_args.extend(recipients) if message.get("invite_only_stream"): result = zcrypt_encrypt_content(zephyr_class, instance, wrapped_content) if result is None: - send_error_zulip("""%s + send_error_zulip( + """%s Your Zulip-Zephyr mirror bot was unable to forward that last message \ from Zulip to Zephyr because you were sending to a zcrypted Zephyr \ @@ -679,7 +759,9 @@ class and your mirroring bot does not have access to the relevant \ key (perhaps because your AFS tokens expired). That means that while \ Zulip users (like you) received it, Zephyr users did not. -%s""" % (support_heading, support_closing)) +%s""" + % (support_heading, support_closing) + ) return # Proceed with sending a zcrypted message @@ -687,22 +769,24 @@ Zulip users (like you) received it, Zephyr users did not. zwrite_args.extend(["-O", "crypt"]) if options.test_mode: - logger.debug("Would have forwarded: %s\n%s" % - (zwrite_args, wrapped_content)) + logger.debug("Would have forwarded: %s\n%s" % (zwrite_args, wrapped_content)) return (code, stderr) = send_authed_zephyr(zwrite_args, wrapped_content) if code == 0 and stderr == "": return elif code == 0: - send_error_zulip("""%s + send_error_zulip( + """%s Your last message was successfully mirrored to zephyr, but zwrite \ returned the following warning: %s -%s""" % (support_heading, stderr, support_closing)) +%s""" + % (support_heading, stderr, support_closing) + ) return elif code != 0 and ( stderr.startswith("zwrite: Ticket expired while sending notice to ") @@ -714,7 +798,8 @@ returned the following warning: if code == 0: if options.ignore_expired_tickets: return - send_error_zulip("""%s + send_error_zulip( + """%s Your last message was forwarded from Zulip to Zephyr unauthenticated, \ because your Kerberos tickets have expired. It was sent successfully, \ @@ -722,13 +807,16 @@ but please renew your Kerberos tickets in the screen session where you \ are running the Zulip-Zephyr mirroring bot, so we can send \ authenticated Zephyr messages for you again. -%s""" % (support_heading, support_closing)) +%s""" + % (support_heading, support_closing) + ) return # zwrite failed and it wasn't because of expired tickets: This is # probably because the recipient isn't subscribed to personals, # but regardless, we should just notify the user. - send_error_zulip("""%s + send_error_zulip( + """%s Your Zulip-Zephyr mirror bot was unable to forward that last message \ from Zulip to Zephyr. That means that while Zulip users (like you) \ @@ -736,20 +824,22 @@ received it, Zephyr users did not. The error message from zwrite was: %s -%s""" % (support_heading, stderr, support_closing)) +%s""" + % (support_heading, stderr, support_closing) + ) return + def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None: # The key string can be used to direct any type of text. - if (message["sender_email"] == zulip_account_email): + if message["sender_email"] == zulip_account_email: if not ( (message["type"] == "stream") or ( message["type"] == "private" and False not in [ - u["email"].lower().endswith("mit.edu") - for u in message["display_recipient"] + u["email"].lower().endswith("mit.edu") for u in message["display_recipient"] ] ) ): @@ -758,8 +848,9 @@ def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None: return timestamp_now = int(time.time()) if float(message["timestamp"]) < timestamp_now - 15: - logger.warning("Skipping out of order message: %s < %s" % - (message["timestamp"], timestamp_now)) + logger.warning( + "Skipping out of order message: %s < %s" % (message["timestamp"], timestamp_now) + ) return try: forward_to_zephyr(message) @@ -768,6 +859,7 @@ def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None: # whole process logger.exception("Error forwarding message:") + def zulip_to_zephyr(options: optparse.Values) -> NoReturn: # Sync messages from zulip to zephyr logger.info("Starting syncing messages.") @@ -779,6 +871,7 @@ def zulip_to_zephyr(options: optparse.Values) -> NoReturn: logger.exception("Error syncing messages:") backoff.fail() + def subscribed_to_mail_messages() -> bool: # In case we have lost our AFS tokens and those won't be able to # parse the Zephyr subs file, first try reading in result of this @@ -787,12 +880,13 @@ def subscribed_to_mail_messages() -> bool: if stored_result is not None: return stored_result == "True" for (cls, instance, recipient) in parse_zephyr_subs(verbose=False): - if (cls.lower() == "mail" and instance.lower() == "inbox"): + if cls.lower() == "mail" and instance.lower() == "inbox": os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "True" return True os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "False" return False + def add_zulip_subscriptions(verbose: bool) -> None: zephyr_subscriptions = set() skipped = set() @@ -805,7 +899,14 @@ def add_zulip_subscriptions(verbose: bool) -> None: # We don't support subscribing to (message, *) if instance == "*": if recipient == "*": - skipped.add((cls, instance, recipient, "subscribing to all of class message is not supported.")) + skipped.add( + ( + cls, + instance, + recipient, + "subscribing to all of class message is not supported.", + ) + ) continue # If you're on -i white-magic on zephyr, get on stream white-magic on zulip # instead of subscribing to stream "message" on zulip @@ -826,8 +927,10 @@ def add_zulip_subscriptions(verbose: bool) -> None: zephyr_subscriptions.add(cls) if len(zephyr_subscriptions) != 0: - res = zulip_client.add_subscriptions(list({"name": stream} for stream in zephyr_subscriptions), - authorization_errors_fatal=False) + res = zulip_client.add_subscriptions( + list({"name": stream} for stream in zephyr_subscriptions), + authorization_errors_fatal=False, + ) if res.get("result") != "success": logger.error("Error subscribing to streams:\n%s" % (res["msg"],)) return @@ -839,9 +942,15 @@ def add_zulip_subscriptions(verbose: bool) -> None: if already is not None and len(already) > 0: logger.info("\nAlready subscribed to: %s" % (", ".join(list(already.values())[0]),)) if new is not None and len(new) > 0: - logger.info("\nSuccessfully subscribed to: %s" % (", ".join(list(new.values())[0]),)) + logger.info( + "\nSuccessfully subscribed to: %s" % (", ".join(list(new.values())[0]),) + ) if unauthorized is not None and len(unauthorized) > 0: - logger.info("\n" + "\n".join(textwrap.wrap("""\ + logger.info( + "\n" + + "\n".join( + textwrap.wrap( + """\ The following streams you have NOT been subscribed to, because they have been configured in Zulip as invitation-only streams. This was done at the request of users of these Zephyr classes, usually @@ -850,11 +959,19 @@ via zcrypt (in Zulip, we achieve the same privacy goals through invitation-only If you wish to read these streams in Zulip, you need to contact the people who are on these streams and already use Zulip. They can subscribe you to them via the "streams" page in the Zulip web interface: -""")) + "\n\n %s" % (", ".join(unauthorized),)) +""" + ) + ) + + "\n\n %s" % (", ".join(unauthorized),) + ) if len(skipped) > 0: if verbose: - logger.info("\n" + "\n".join(textwrap.wrap("""\ + logger.info( + "\n" + + "\n".join( + textwrap.wrap( + """\ You have some lines in ~/.zephyr.subs that could not be synced to your Zulip subscriptions because they do not use "*" as both the instance and recipient and not one of @@ -863,7 +980,11 @@ Zulip has a mechanism for forwarding. Zulip does not allow subscribing to only some subjects on a Zulip stream, so this tool has not created a corresponding Zulip subscription to these lines in ~/.zephyr.subs: -""")) + "\n") +""" + ) + ) + + "\n" + ) for (cls, instance, recipient, reason) in skipped: if verbose: @@ -873,15 +994,25 @@ Zulip subscription to these lines in ~/.zephyr.subs: logger.info(" [%s,%s,%s]" % (cls, instance, recipient)) if len(skipped) > 0: if verbose: - logger.info("\n" + "\n".join(textwrap.wrap("""\ + logger.info( + "\n" + + "\n".join( + textwrap.wrap( + """\ If you wish to be subscribed to any Zulip streams related to these .zephyrs.subs lines, please do so via the Zulip web interface. -""")) + "\n") +""" + ) + ) + + "\n" + ) + def valid_stream_name(name: str) -> bool: return name != "" + def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]: zephyr_subscriptions = set() # type: Set[Tuple[str, str, str]] subs_file = os.path.join(os.environ["HOME"], ".zephyr.subs") @@ -910,6 +1041,7 @@ def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]: zephyr_subscriptions.add((cls.strip(), instance.strip(), recipient.strip())) return zephyr_subscriptions + def open_logger() -> logging.Logger: if options.log_path is not None: log_file = options.log_path @@ -919,8 +1051,7 @@ def open_logger() -> logging.Logger: else: log_file = "/var/log/zulip/mirror-log" else: - f = tempfile.NamedTemporaryFile(prefix="zulip-log.%s." % (options.user,), - delete=False) + f = tempfile.NamedTemporaryFile(prefix="zulip-log.%s." % (options.user,), delete=False) log_file = f.name # Close the file descriptor, since the logging system will # reopen it anyway. @@ -935,6 +1066,7 @@ def open_logger() -> logging.Logger: logger.addHandler(file_handler) return logger + def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> None: if direction_name is None: log_format = "%(message)s" @@ -949,89 +1081,70 @@ def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> N for handler in root_logger.handlers: handler.setFormatter(formatter) + def parse_args() -> Tuple[optparse.Values, List[str]]: parser = optparse.OptionParser() - parser.add_option('--forward-class-messages', - default=False, - help=optparse.SUPPRESS_HELP, - action='store_true') - parser.add_option('--shard', - help=optparse.SUPPRESS_HELP) - parser.add_option('--noshard', - default=False, - help=optparse.SUPPRESS_HELP, - action='store_true') - parser.add_option('--resend-log', - dest='logs_to_resend', - help=optparse.SUPPRESS_HELP) - parser.add_option('--enable-resend-log', - dest='resend_log_path', - help=optparse.SUPPRESS_HELP) - parser.add_option('--log-path', - dest='log_path', - help=optparse.SUPPRESS_HELP) - parser.add_option('--stream-file-path', - dest='stream_file_path', - default="/home/zulip/public_streams", - help=optparse.SUPPRESS_HELP) - parser.add_option('--no-forward-personals', - dest='forward_personals', - help=optparse.SUPPRESS_HELP, - default=True, - action='store_false') - parser.add_option('--forward-mail-zephyrs', - dest='forward_mail_zephyrs', - help=optparse.SUPPRESS_HELP, - default=False, - action='store_true') - parser.add_option('--no-forward-from-zulip', - default=True, - dest='forward_from_zulip', - help=optparse.SUPPRESS_HELP, - action='store_false') - parser.add_option('--verbose', - default=False, - help=optparse.SUPPRESS_HELP, - action='store_true') - parser.add_option('--sync-subscriptions', - default=False, - action='store_true') - parser.add_option('--ignore-expired-tickets', - default=False, - action='store_true') - parser.add_option('--site', - default=DEFAULT_SITE, - help=optparse.SUPPRESS_HELP) - parser.add_option('--on-startup-command', - default=None, - help=optparse.SUPPRESS_HELP) - parser.add_option('--user', - default=os.environ["USER"], - help=optparse.SUPPRESS_HELP) - parser.add_option('--stamp-path', - default="/afs/athena.mit.edu/user/t/a/tabbott/for_friends", - help=optparse.SUPPRESS_HELP) - parser.add_option('--session-path', - default=None, - help=optparse.SUPPRESS_HELP) - parser.add_option('--nagios-class', - default=None, - help=optparse.SUPPRESS_HELP) - parser.add_option('--nagios-path', - default=None, - help=optparse.SUPPRESS_HELP) - parser.add_option('--use-sessions', - default=False, - action='store_true', - help=optparse.SUPPRESS_HELP) - parser.add_option('--test-mode', - default=False, - help=optparse.SUPPRESS_HELP, - action='store_true') - parser.add_option('--api-key-file', - default=os.path.join(os.environ["HOME"], "Private", ".humbug-api-key")) + parser.add_option( + '--forward-class-messages', default=False, help=optparse.SUPPRESS_HELP, action='store_true' + ) + parser.add_option('--shard', help=optparse.SUPPRESS_HELP) + parser.add_option('--noshard', default=False, help=optparse.SUPPRESS_HELP, action='store_true') + parser.add_option('--resend-log', dest='logs_to_resend', help=optparse.SUPPRESS_HELP) + parser.add_option('--enable-resend-log', dest='resend_log_path', help=optparse.SUPPRESS_HELP) + parser.add_option('--log-path', dest='log_path', help=optparse.SUPPRESS_HELP) + parser.add_option( + '--stream-file-path', + dest='stream_file_path', + default="/home/zulip/public_streams", + help=optparse.SUPPRESS_HELP, + ) + parser.add_option( + '--no-forward-personals', + dest='forward_personals', + help=optparse.SUPPRESS_HELP, + default=True, + action='store_false', + ) + parser.add_option( + '--forward-mail-zephyrs', + dest='forward_mail_zephyrs', + help=optparse.SUPPRESS_HELP, + default=False, + action='store_true', + ) + parser.add_option( + '--no-forward-from-zulip', + default=True, + dest='forward_from_zulip', + help=optparse.SUPPRESS_HELP, + action='store_false', + ) + parser.add_option('--verbose', default=False, help=optparse.SUPPRESS_HELP, action='store_true') + parser.add_option('--sync-subscriptions', default=False, action='store_true') + parser.add_option('--ignore-expired-tickets', default=False, action='store_true') + parser.add_option('--site', default=DEFAULT_SITE, help=optparse.SUPPRESS_HELP) + parser.add_option('--on-startup-command', default=None, help=optparse.SUPPRESS_HELP) + parser.add_option('--user', default=os.environ["USER"], help=optparse.SUPPRESS_HELP) + parser.add_option( + '--stamp-path', + default="/afs/athena.mit.edu/user/t/a/tabbott/for_friends", + help=optparse.SUPPRESS_HELP, + ) + parser.add_option('--session-path', default=None, help=optparse.SUPPRESS_HELP) + parser.add_option('--nagios-class', default=None, help=optparse.SUPPRESS_HELP) + parser.add_option('--nagios-path', default=None, help=optparse.SUPPRESS_HELP) + parser.add_option( + '--use-sessions', default=False, action='store_true', help=optparse.SUPPRESS_HELP + ) + parser.add_option( + '--test-mode', default=False, help=optparse.SUPPRESS_HELP, action='store_true' + ) + parser.add_option( + '--api-key-file', default=os.path.join(os.environ["HOME"], "Private", ".humbug-api-key") + ) return parser.parse_args() + def die_gracefully(signal: int, frame: FrameType) -> None: if CURRENT_STATE == States.ZulipToZephyr or CURRENT_STATE == States.ChildSending: # this is a child process, so we want os._exit (no clean-up necessary) @@ -1047,6 +1160,7 @@ def die_gracefully(signal: int, frame: FrameType) -> None: sys.exit(1) + if __name__ == "__main__": # Set the SIGCHLD handler back to SIG_DFL to prevent these errors # when importing the "requests" module after being restarted using @@ -1070,10 +1184,18 @@ if __name__ == "__main__": api_key = os.environ.get("HUMBUG_API_KEY") else: if not os.path.exists(options.api_key_file): - logger.error("\n" + "\n".join(textwrap.wrap("""\ + logger.error( + "\n" + + "\n".join( + textwrap.wrap( + """\ Could not find API key file. You need to either place your api key file at %s, -or specify the --api-key-file option.""" % (options.api_key_file,)))) +or specify the --api-key-file option.""" + % (options.api_key_file,) + ) + ) + ) sys.exit(1) api_key = open(options.api_key_file).read().strip() # Store the API key in the environment so that our children @@ -1086,12 +1208,14 @@ or specify the --api-key-file option.""" % (options.api_key_file,)))) zulip_account_email = options.user + "@mit.edu" import zulip + zulip_client = zulip.Client( email=zulip_account_email, api_key=api_key, verbose=True, client="zephyr_mirror", - site=options.site) + site=options.site, + ) start_time = time.time() @@ -1110,9 +1234,11 @@ or specify the --api-key-file option.""" % (options.api_key_file,)))) elif options.user is not None: # Personals mirror on behalf of another user. pgrep_query = "%s.*--user=%s" % (pgrep_query, options.user) - proc = subprocess.Popen(['pgrep', '-U', os.environ["USER"], "-f", pgrep_query], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) + proc = subprocess.Popen( + ['pgrep', '-U', os.environ["USER"], "-f", pgrep_query], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) out, _err_unused = proc.communicate() for pid in map(int, out.split()): if pid == os.getpid() or pid == os.getppid(): @@ -1149,6 +1275,7 @@ or specify the --api-key-file option.""" % (options.api_key_file,)))) CURRENT_STATE = States.ZephyrToZulip import zephyr + logger_name = "zephyr=>zulip" if options.shard is not None: logger_name += "(%s)" % (options.shard,) diff --git a/zulip/setup.py b/zulip/setup.py index 1756ad1..48616a6 100755 --- a/zulip/setup.py +++ b/zulip/setup.py @@ -8,20 +8,24 @@ from typing import Any, Dict, Generator, List, Tuple with open("README.md") as fh: long_description = fh.read() + def version() -> str: version_py = os.path.join(os.path.dirname(__file__), "zulip", "__init__.py") with open(version_py) as in_handle: - version_line = next(itertools.dropwhile(lambda x: not x.startswith("__version__"), - in_handle)) + version_line = next( + itertools.dropwhile(lambda x: not x.startswith("__version__"), in_handle) + ) version = version_line.split('=')[-1].strip().replace('"', '') return version + def recur_expand(target_root: Any, dir: Any) -> Generator[Tuple[str, List[str]], None, None]: for root, _, files in os.walk(dir): paths = [os.path.join(root, f) for f in files] if len(paths): yield os.path.join(target_root, root), paths + # We should be installable with either setuptools or distutils. package_info = dict( name='zulip', @@ -56,22 +60,24 @@ package_info = dict( 'zulip-send=zulip.send:main', 'zulip-api-examples=zulip.api_examples:main', 'zulip-matrix-bridge=integrations.bridge_with_matrix.matrix_bridge:main', - 'zulip-api=zulip.cli:cli' + 'zulip-api=zulip.cli:cli', ], }, package_data={'zulip': ["py.typed"]}, ) # type: Dict[str, Any] setuptools_info = dict( - install_requires=['requests[security]>=0.12.1', - 'matrix_client', - 'distro', - 'click', - ], + install_requires=[ + 'requests[security]>=0.12.1', + 'matrix_client', + 'distro', + 'click', + ], ) try: from setuptools import find_packages, setup + package_info.update(setuptools_info) package_info['packages'] = find_packages(exclude=['tests']) @@ -82,7 +88,8 @@ except ImportError: # Manual dependency check try: import requests - assert(LooseVersion(requests.__version__) >= LooseVersion('0.12.1')) + + assert LooseVersion(requests.__version__) >= LooseVersion('0.12.1') except (ImportError, AssertionError): print("requests >=0.12.1 is not installed", file=sys.stderr) sys.exit(1) diff --git a/zulip/tests/test_default_arguments.py b/zulip/tests/test_default_arguments.py index ac8cf4c..4385abe 100755 --- a/zulip/tests/test_default_arguments.py +++ b/zulip/tests/test_default_arguments.py @@ -12,7 +12,6 @@ from zulip import ZulipError class TestDefaultArguments(TestCase): - def test_invalid_arguments(self) -> None: parser = zulip.add_default_arguments(argparse.ArgumentParser(usage="lorem ipsum")) with self.assertRaises(SystemExit) as cm: @@ -20,14 +19,18 @@ class TestDefaultArguments(TestCase): parser.parse_args(['invalid argument']) self.assertEqual(cm.exception.code, 2) # Assert that invalid arguments exit with printing the full usage (non-standard behavior) - self.assertTrue(mock_stderr.getvalue().startswith("""usage: lorem ipsum + self.assertTrue( + mock_stderr.getvalue().startswith( + """usage: lorem ipsum optional arguments: -h, --help show this help message and exit Zulip API configuration: --site ZULIP_SITE Zulip server URI -""")) +""" + ) + ) @patch('os.path.exists', return_value=False) def test_config_path_with_tilde(self, mock_os_path_exists: bool) -> None: @@ -37,8 +40,12 @@ Zulip API configuration: with self.assertRaises(ZulipError) as cm: zulip.init_from_options(args) expanded_test_path = os.path.abspath(os.path.expanduser(test_path)) - self.assertEqual(str(cm.exception), 'api_key or email not specified and ' - 'file {} does not exist'.format(expanded_test_path)) + self.assertEqual( + str(cm.exception), + 'api_key or email not specified and ' + 'file {} does not exist'.format(expanded_test_path), + ) + if __name__ == '__main__': unittest.main() diff --git a/zulip/tests/test_hash_util_decode.py b/zulip/tests/test_hash_util_decode.py index 3172bc7..512fcca 100644 --- a/zulip/tests/test_hash_util_decode.py +++ b/zulip/tests/test_hash_util_decode.py @@ -20,5 +20,6 @@ class TestHashUtilDecode(TestCase): with self.subTest(encoded_string=encoded_string): self.assertEqual(zulip.hash_util_decode(encoded_string), decoded_string) + if __name__ == '__main__': unittest.main() diff --git a/zulip/zulip/__init__.py b/zulip/zulip/__init__.py index 3000ee0..811fd81 100644 --- a/zulip/zulip/__init__.py +++ b/zulip/zulip/__init__.py @@ -39,12 +39,13 @@ logger = logging.getLogger(__name__) # Check that we have a recent enough version # Older versions don't provide the 'json' attribute on responses. -assert(LooseVersion(requests.__version__) >= LooseVersion('0.12.1')) +assert LooseVersion(requests.__version__) >= LooseVersion('0.12.1') # In newer versions, the 'json' attribute is a function, not a property requests_json_is_function = callable(requests.Response.json) API_VERSTRING = "v1/" + class CountingBackoff: def __init__( self, @@ -83,8 +84,7 @@ class CountingBackoff: def fail(self) -> None: self._check_success_timeout() - self.number_of_retries = min(self.number_of_retries + 1, - self.maximum_retries) + self.number_of_retries = min(self.number_of_retries + 1, self.maximum_retries) self.last_attempt_time = time.time() def _check_success_timeout(self) -> None: @@ -95,6 +95,7 @@ class CountingBackoff: ): self.number_of_retries = 0 + class RandomExponentialBackoff(CountingBackoff): def fail(self) -> None: super().fail() @@ -109,9 +110,11 @@ class RandomExponentialBackoff(CountingBackoff): print(message) time.sleep(delay) + def _default_client() -> str: return "ZulipPython/" + __version__ + def add_default_arguments( parser: argparse.ArgumentParser, patch_error_handling: bool = True, @@ -119,125 +122,141 @@ def add_default_arguments( ) -> argparse.ArgumentParser: if patch_error_handling: + def custom_error_handling(self: argparse.ArgumentParser, message: str) -> None: self.print_help(sys.stderr) self.exit(2, '{}: error: {}\n'.format(self.prog, message)) + parser.error = types.MethodType(custom_error_handling, parser) # type: ignore # patching function if allow_provisioning: - parser.add_argument('--provision', - action='store_true', - dest="provision", - help="install dependencies for this script (found in requirements.txt)") + parser.add_argument( + '--provision', + action='store_true', + dest="provision", + help="install dependencies for this script (found in requirements.txt)", + ) group = parser.add_argument_group('Zulip API configuration') - group.add_argument('--site', - dest="zulip_site", - help="Zulip server URI", - default=None) - group.add_argument('--api-key', - dest="zulip_api_key", - action='store') - group.add_argument('--user', - dest='zulip_email', - help='Email address of the calling bot or user.') - group.add_argument('--config-file', - action='store', - dest="zulip_config_file", - help='''Location of an ini file containing the above - information. (default ~/.zuliprc)''') - group.add_argument('-v', '--verbose', - action='store_true', - help='Provide detailed output.') - group.add_argument('--client', - action='store', - default=None, - dest="zulip_client", - help=argparse.SUPPRESS) - group.add_argument('--insecure', - action='store_true', - dest='insecure', - help='''Do not verify the server certificate. - The https connection will not be secure.''') - group.add_argument('--cert-bundle', - action='store', - dest='cert_bundle', - help='''Specify a file containing either the + group.add_argument('--site', dest="zulip_site", help="Zulip server URI", default=None) + group.add_argument('--api-key', dest="zulip_api_key", action='store') + group.add_argument( + '--user', dest='zulip_email', help='Email address of the calling bot or user.' + ) + group.add_argument( + '--config-file', + action='store', + dest="zulip_config_file", + help='''Location of an ini file containing the above + information. (default ~/.zuliprc)''', + ) + group.add_argument('-v', '--verbose', action='store_true', help='Provide detailed output.') + group.add_argument( + '--client', action='store', default=None, dest="zulip_client", help=argparse.SUPPRESS + ) + group.add_argument( + '--insecure', + action='store_true', + dest='insecure', + help='''Do not verify the server certificate. + The https connection will not be secure.''', + ) + group.add_argument( + '--cert-bundle', + action='store', + dest='cert_bundle', + help='''Specify a file containing either the server certificate, or a set of trusted CA certificates. This will be used to verify the server's identity. All - certificates should be PEM encoded.''') - group.add_argument('--client-cert', - action='store', - dest='client_cert', - help='''Specify a file containing a client - certificate (not needed for most deployments).''') - group.add_argument('--client-cert-key', - action='store', - dest='client_cert_key', - help='''Specify a file containing the client + certificates should be PEM encoded.''', + ) + group.add_argument( + '--client-cert', + action='store', + dest='client_cert', + help='''Specify a file containing a client + certificate (not needed for most deployments).''', + ) + group.add_argument( + '--client-cert-key', + action='store', + dest='client_cert_key', + help='''Specify a file containing the client certificate's key (if it is in a separate - file).''') + file).''', + ) return parser + # This method might seem redundant with `add_default_arguments()`, # except for the fact that is uses the deprecated `optparse` module. # We still keep it for legacy support of out-of-tree bots and integrations # depending on it. def generate_option_group(parser: optparse.OptionParser, prefix: str = '') -> optparse.OptionGroup: - logging.warning("""zulip.generate_option_group is based on optparse, which + logging.warning( + """zulip.generate_option_group is based on optparse, which is now deprecated. We recommend migrating to argparse and - using zulip.add_default_arguments instead.""") + using zulip.add_default_arguments instead.""" + ) group = optparse.OptionGroup(parser, 'Zulip API configuration') - group.add_option('--%ssite' % (prefix,), - dest="zulip_site", - help="Zulip server URI", - default=None) - group.add_option('--%sapi-key' % (prefix,), - dest="zulip_api_key", - action='store') - group.add_option('--%suser' % (prefix,), - dest='zulip_email', - help='Email address of the calling bot or user.') - group.add_option('--%sconfig-file' % (prefix,), - action='store', - dest="zulip_config_file", - help='Location of an ini file containing the\nabove information. (default ~/.zuliprc)') - group.add_option('-v', '--verbose', - action='store_true', - help='Provide detailed output.') - group.add_option('--%sclient' % (prefix,), - action='store', - default=None, - dest="zulip_client", - help=optparse.SUPPRESS_HELP) - group.add_option('--insecure', - action='store_true', - dest='insecure', - help='''Do not verify the server certificate. - The https connection will not be secure.''') - group.add_option('--cert-bundle', - action='store', - dest='cert_bundle', - help='''Specify a file containing either the + group.add_option( + '--%ssite' % (prefix,), dest="zulip_site", help="Zulip server URI", default=None + ) + group.add_option('--%sapi-key' % (prefix,), dest="zulip_api_key", action='store') + group.add_option( + '--%suser' % (prefix,), dest='zulip_email', help='Email address of the calling bot or user.' + ) + group.add_option( + '--%sconfig-file' % (prefix,), + action='store', + dest="zulip_config_file", + help='Location of an ini file containing the\nabove information. (default ~/.zuliprc)', + ) + group.add_option('-v', '--verbose', action='store_true', help='Provide detailed output.') + group.add_option( + '--%sclient' % (prefix,), + action='store', + default=None, + dest="zulip_client", + help=optparse.SUPPRESS_HELP, + ) + group.add_option( + '--insecure', + action='store_true', + dest='insecure', + help='''Do not verify the server certificate. + The https connection will not be secure.''', + ) + group.add_option( + '--cert-bundle', + action='store', + dest='cert_bundle', + help='''Specify a file containing either the server certificate, or a set of trusted CA certificates. This will be used to verify the server's identity. All - certificates should be PEM encoded.''') - group.add_option('--client-cert', - action='store', - dest='client_cert', - help='''Specify a file containing a client - certificate (not needed for most deployments).''') - group.add_option('--client-cert-key', - action='store', - dest='client_cert_key', - help='''Specify a file containing the client + certificates should be PEM encoded.''', + ) + group.add_option( + '--client-cert', + action='store', + dest='client_cert', + help='''Specify a file containing a client + certificate (not needed for most deployments).''', + ) + group.add_option( + '--client-cert-key', + action='store', + dest='client_cert_key', + help='''Specify a file containing the client certificate's key (if it is in a separate - file).''') + file).''', + ) return group + def init_from_options(options: Any, client: Optional[str] = None) -> 'Client': if getattr(options, 'provision', False): @@ -246,39 +265,54 @@ def init_from_options(options: Any, client: Optional[str] = None) -> 'Client': import pip except ImportError: traceback.print_exc() - print("Module `pip` is not installed. To install `pip`, follow the instructions here: " - "https://pip.pypa.io/en/stable/installing/") + print( + "Module `pip` is not installed. To install `pip`, follow the instructions here: " + "https://pip.pypa.io/en/stable/installing/" + ) sys.exit(1) if not pip.main(['install', '--upgrade', '--requirement', requirements_path]): - print("{color_green}You successfully provisioned the dependencies for {script}.{end_color}".format( - color_green='\033[92m', end_color='\033[0m', - script=os.path.splitext(os.path.basename(sys.argv[0]))[0])) + print( + "{color_green}You successfully provisioned the dependencies for {script}.{end_color}".format( + color_green='\033[92m', + end_color='\033[0m', + script=os.path.splitext(os.path.basename(sys.argv[0]))[0], + ) + ) sys.exit(0) if options.zulip_client is not None: client = options.zulip_client elif client is None: client = _default_client() - return Client(email=options.zulip_email, api_key=options.zulip_api_key, - config_file=options.zulip_config_file, verbose=options.verbose, - site=options.zulip_site, client=client, - cert_bundle=options.cert_bundle, insecure=options.insecure, - client_cert=options.client_cert, - client_cert_key=options.client_cert_key) + return Client( + email=options.zulip_email, + api_key=options.zulip_api_key, + config_file=options.zulip_config_file, + verbose=options.verbose, + site=options.zulip_site, + client=client, + cert_bundle=options.cert_bundle, + insecure=options.insecure, + client_cert=options.client_cert, + client_cert_key=options.client_cert_key, + ) + def get_default_config_filename() -> Optional[str]: if os.environ.get("HOME") is None: return None config_file = os.path.join(os.environ["HOME"], ".zuliprc") - if ( - not os.path.exists(config_file) - and os.path.exists(os.path.join(os.environ["HOME"], ".humbugrc")) + if not os.path.exists(config_file) and os.path.exists( + os.path.join(os.environ["HOME"], ".humbugrc") ): - raise ZulipError("The Zulip API configuration file is now ~/.zuliprc; please run:\n\n" - " mv ~/.humbugrc ~/.zuliprc\n") + raise ZulipError( + "The Zulip API configuration file is now ~/.zuliprc; please run:\n\n" + " mv ~/.humbugrc ~/.zuliprc\n" + ) return config_file + def validate_boolean_field(field: Optional[Text]) -> Union[bool, None]: if not isinstance(field, str): return None @@ -292,24 +326,38 @@ def validate_boolean_field(field: Optional[Text]) -> Union[bool, None]: else: return None + class ZulipError(Exception): pass + class ConfigNotFoundError(ZulipError): pass + class MissingURLError(ZulipError): pass + class UnrecoverableNetworkError(ZulipError): pass + class Client: - def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None, config_file: Optional[str] = None, - verbose: bool = False, retry_on_errors: bool = True, - site: Optional[str] = None, client: Optional[str] = None, - cert_bundle: Optional[str] = None, insecure: Optional[bool] = None, - client_cert: Optional[str] = None, client_cert_key: Optional[str] = None) -> None: + def __init__( + self, + email: Optional[str] = None, + api_key: Optional[str] = None, + config_file: Optional[str] = None, + verbose: bool = False, + retry_on_errors: bool = True, + site: Optional[str] = None, + client: Optional[str] = None, + cert_bundle: Optional[str] = None, + insecure: Optional[bool] = None, + client_cert: Optional[str] = None, + client_cert_key: Optional[str] = None, + ) -> None: if client is None: client = _default_client() @@ -340,10 +388,11 @@ class Client: insecure = validate_boolean_field(insecure_setting) if insecure is None: - raise ZulipError("The ZULIP_ALLOW_INSECURE environment " - "variable is set to '{}', it must be " - "'true' or 'false'" - .format(insecure_setting)) + raise ZulipError( + "The ZULIP_ALLOW_INSECURE environment " + "variable is set to '{}', it must be " + "'true' or 'false'".format(insecure_setting) + ) if config_file is None: config_file = get_default_config_filename() @@ -371,15 +420,19 @@ class Client: insecure = validate_boolean_field(insecure_setting) if insecure is None: - raise ZulipError("insecure is set to '{}', it must be " - "'true' or 'false' if it is used in {}" - .format(insecure_setting, config_file)) + raise ZulipError( + "insecure is set to '{}', it must be " + "'true' or 'false' if it is used in {}".format( + insecure_setting, config_file + ) + ) elif None in (api_key, email): - raise ConfigNotFoundError("api_key or email not specified and file %s does not exist" - % (config_file,)) + raise ConfigNotFoundError( + "api_key or email not specified and file %s does not exist" % (config_file,) + ) - assert(api_key is not None and email is not None) + assert api_key is not None and email is not None self.api_key = api_key self.email = email self.verbose = verbose @@ -401,14 +454,15 @@ class Client: self.client_name = client if insecure: - logger.warning('Insecure mode enabled. The server\'s SSL/TLS ' - 'certificate will not be validated, making the ' - 'HTTPS connection potentially insecure') + logger.warning( + 'Insecure mode enabled. The server\'s SSL/TLS ' + 'certificate will not be validated, making the ' + 'HTTPS connection potentially insecure' + ) self.tls_verification = False # type: Union[bool, str] elif cert_bundle is not None: if not os.path.isfile(cert_bundle): - raise ConfigNotFoundError("tls bundle '%s' does not exist" - % (cert_bundle,)) + raise ConfigNotFoundError("tls bundle '%s' does not exist" % (cert_bundle,)) self.tls_verification = cert_bundle else: # Default behavior: verify against system CA certificates @@ -416,16 +470,18 @@ class Client: if client_cert is None: if client_cert_key is not None: - raise ConfigNotFoundError("client cert key '%s' specified, but no client cert public part provided" - % (client_cert_key,)) + raise ConfigNotFoundError( + "client cert key '%s' specified, but no client cert public part provided" + % (client_cert_key,) + ) else: # we have a client cert if not os.path.isfile(client_cert): - raise ConfigNotFoundError("client cert '%s' does not exist" - % (client_cert,)) + raise ConfigNotFoundError("client cert '%s' does not exist" % (client_cert,)) if client_cert_key is not None: if not os.path.isfile(client_cert_key): - raise ConfigNotFoundError("client cert key '%s' does not exist" - % (client_cert_key,)) + raise ConfigNotFoundError( + "client cert key '%s' does not exist" % (client_cert_key,) + ) self.client_cert = client_cert self.client_cert_key = client_cert_key @@ -442,8 +498,11 @@ class Client: # Build a client cert object for requests if self.client_cert_key is not None: - assert(self.client_cert is not None) # Otherwise ZulipError near end of __init__ - client_cert = (self.client_cert, self.client_cert_key) # type: Union[None, str, Tuple[str, str]] + assert self.client_cert is not None # Otherwise ZulipError near end of __init__ + client_cert = ( + self.client_cert, + self.client_cert_key, + ) # type: Union[None, str, Tuple[str, str]] else: client_cert = self.client_cert @@ -479,8 +538,15 @@ class Client: vendor_version=vendor_version, ) - def do_api_query(self, orig_request: Mapping[str, Any], url: str, method: str = "POST", - longpolling: bool = False, files: Optional[List[IO[Any]]] = None, timeout: Optional[float] = None) -> Dict[str, Any]: + def do_api_query( + self, + orig_request: Mapping[str, Any], + url: str, + method: str = "POST", + longpolling: bool = False, + files: Optional[List[IO[Any]]] = None, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: if files is None: files = [] @@ -488,10 +554,10 @@ class Client: # When long-polling, set timeout to 90 sec as a balance # between a low traffic rate and a still reasonable latency # time in case of a connection failure. - request_timeout = 90. + request_timeout = 90.0 else: # Otherwise, 15s should be plenty of time. - request_timeout = 15. if not timeout else timeout + request_timeout = 15.0 if not timeout else timeout request = {} req_files = [] @@ -506,7 +572,7 @@ class Client: req_files.append((f.name, f)) self.ensure_session() - assert(self.session is not None) + assert self.session is not None query_state = { 'had_error_retry': False, @@ -519,8 +585,13 @@ class Client: return False if self.verbose: if not query_state["had_error_retry"]: - sys.stdout.write("zulip API(%s): connection error%s -- retrying." % - (url.split(API_VERSTRING, 2)[0], error_string,)) + sys.stdout.write( + "zulip API(%s): connection error%s -- retrying." + % ( + url.split(API_VERSTRING, 2)[0], + error_string, + ) + ) query_state["had_error_retry"] = True else: sys.stdout.write(".") @@ -554,7 +625,8 @@ class Client: method, urllib.parse.urljoin(self.base_url, url), timeout=request_timeout, - **kwargs) + **kwargs, + ) self.has_connected = True @@ -578,8 +650,10 @@ class Client: continue else: end_error_retry(False) - return {'msg': "Connection error:\n%s" % (traceback.format_exc(),), - "result": "connection-error"} + return { + 'msg': "Connection error:\n%s" % (traceback.format_exc(),), + "result": "connection-error", + } except requests.exceptions.ConnectionError: if not self.has_connected: # If we have never successfully connected to the server, don't @@ -591,12 +665,16 @@ class Client: if error_retry(""): continue end_error_retry(False) - return {'msg': "Connection error:\n%s" % (traceback.format_exc(),), - "result": "connection-error"} + return { + 'msg': "Connection error:\n%s" % (traceback.format_exc(),), + "result": "connection-error", + } except Exception: # We'll split this out into more cases as we encounter new bugs. - return {'msg': "Unexpected error:\n%s" % (traceback.format_exc(),), - "result": "unexpected-error"} + return { + 'msg': "Unexpected error:\n%s" % (traceback.format_exc(),), + "result": "unexpected-error", + } try: if requests_json_is_function: @@ -610,11 +688,21 @@ class Client: end_error_retry(True) return json_result end_error_retry(False) - return {'msg': "Unexpected error from the server", "result": "http-error", - "status_code": res.status_code} + return { + 'msg': "Unexpected error from the server", + "result": "http-error", + "status_code": res.status_code, + } - def call_endpoint(self, url: Optional[str] = None, method: str = "POST", request: Optional[Dict[str, Any]] = None, - longpolling: bool = False, files: Optional[List[IO[Any]]] = None, timeout: Optional[float] = None) -> Dict[str, Any]: + def call_endpoint( + self, + url: Optional[str] = None, + method: str = "POST", + request: Optional[Dict[str, Any]] = None, + longpolling: bool = False, + files: Optional[List[IO[Any]]] = None, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: if request is None: request = dict() marshalled_request = {} @@ -622,8 +710,14 @@ class Client: if v is not None: marshalled_request[k] = v versioned_url = API_VERSTRING + (url if url is not None else "") - return self.do_api_query(marshalled_request, versioned_url, method=method, - longpolling=longpolling, files=files, timeout=timeout) + return self.do_api_query( + marshalled_request, + versioned_url, + method=method, + longpolling=longpolling, + files=files, + timeout=timeout, + ) def call_on_each_event( self, @@ -664,7 +758,9 @@ class Client: print("HTTP error fetching events -- probably a server restart") elif res["result"] == "connection-error": if self.verbose: - print("Connection error fetching events -- probably server is temporarily down?") + print( + "Connection error fetching events -- probably server is temporarily down?" + ) else: if self.verbose: print("Server returned error:\n%s" % (res["msg"],)) @@ -672,7 +768,9 @@ class Client: # BAD_EVENT_QUEUE_ID check, but we check for the # old string to support legacy Zulip servers. We # should remove that legacy check in 2019. - if res.get("code") == "BAD_EVENT_QUEUE_ID" or res["msg"].startswith("Bad event queue id:"): + if res.get("code") == "BAD_EVENT_QUEUE_ID" or res["msg"].startswith( + "Bad event queue id:" + ): # Our event queue went away, probably because # we were asleep or the server restarted # abnormally. We may have missed some @@ -693,50 +791,42 @@ class Client: last_event_id = max(last_event_id, int(event['id'])) callback(event) - def call_on_each_message(self, callback: Callable[[Dict[str, Any]], None], **kwargs: object) -> None: + def call_on_each_message( + self, callback: Callable[[Dict[str, Any]], None], **kwargs: object + ) -> None: def event_callback(event: Dict[str, Any]) -> None: if event['type'] == 'message': callback(event['message']) + self.call_on_each_event(event_callback, ['message'], None, **kwargs) def get_messages(self, message_filters: Dict[str, Any]) -> Dict[str, Any]: ''' - See examples/get-messages for example usage + See examples/get-messages for example usage ''' - return self.call_endpoint( - url='messages', - method='GET', - request=message_filters - ) + return self.call_endpoint(url='messages', method='GET', request=message_filters) def check_messages_match_narrow(self, **request: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.check_messages_match_narrow(msg_ids=[11, 12], - narrow=[{'operator': 'has', 'operand': 'link'}] - ) - {'result': 'success', 'msg': '', 'messages': [{...}, {...}]} - ''' - return self.call_endpoint( - url='messages/matches_narrow', - method='GET', - request=request + >>> client.check_messages_match_narrow(msg_ids=[11, 12], + narrow=[{'operator': 'has', 'operand': 'link'}] ) + {'result': 'success', 'msg': '', 'messages': [{...}, {...}]} + ''' + return self.call_endpoint(url='messages/matches_narrow', method='GET', request=request) def get_raw_message(self, message_id: int) -> Dict[str, str]: ''' - See examples/get-raw-message for example usage + See examples/get-raw-message for example usage ''' - return self.call_endpoint( - url='messages/{}'.format(message_id), - method='GET' - ) + return self.call_endpoint(url='messages/{}'.format(message_id), method='GET') def send_message(self, message_data: Dict[str, Any]) -> Dict[str, Any]: ''' - See examples/send-message for example usage. + See examples/send-message for example usage. ''' return self.call_endpoint( url='messages', @@ -745,28 +835,22 @@ class Client: def upload_file(self, file: IO[Any]) -> Dict[str, Any]: ''' - See examples/upload-file for example usage. + See examples/upload-file for example usage. ''' - return self.call_endpoint( - url='user_uploads', - files=[file] - ) + return self.call_endpoint(url='user_uploads', files=[file]) def get_attachments(self) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_attachments() - {'result': 'success', 'msg': '', 'attachments': [{...}, {...}]} + >>> client.get_attachments() + {'result': 'success', 'msg': '', 'attachments': [{...}, {...}]} ''' - return self.call_endpoint( - url='attachments', - method='GET' - ) + return self.call_endpoint(url='attachments', method='GET') def update_message(self, message_data: Dict[str, Any]) -> Dict[str, Any]: ''' - See examples/edit-message for example usage. + See examples/edit-message for example usage. ''' return self.call_endpoint( url='messages/%d' % (message_data['message_id'],), @@ -776,29 +860,22 @@ class Client: def delete_message(self, message_id: int) -> Dict[str, Any]: ''' - See examples/delete-message for example usage. + See examples/delete-message for example usage. ''' - return self.call_endpoint( - url='messages/{}'.format(message_id), - method='DELETE' - ) + return self.call_endpoint(url='messages/{}'.format(message_id), method='DELETE') def update_message_flags(self, update_data: Dict[str, Any]) -> Dict[str, Any]: ''' - See examples/update-flags for example usage. + See examples/update-flags for example usage. ''' - return self.call_endpoint( - url='messages/flags', - method='POST', - request=update_data - ) + return self.call_endpoint(url='messages/flags', method='POST', request=update_data) def mark_all_as_read(self) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.mark_all_as_read() - {'result': 'success', 'msg': ''} + >>> client.mark_all_as_read() + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='mark_all_as_read', @@ -807,10 +884,10 @@ class Client: def mark_stream_as_read(self, stream_id: int) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.mark_stream_as_read(42) - {'result': 'success', 'msg': ''} + >>> client.mark_stream_as_read(42) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='mark_stream_as_read', @@ -820,10 +897,10 @@ class Client: def mark_topic_as_read(self, stream_id: int, topic_name: str) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.mark_all_as_read(42, 'new coffee machine') - {'result': 'success', 'msg': ''} + >>> client.mark_all_as_read(42, 'new coffee machine') + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='mark_topic_as_read', @@ -836,24 +913,21 @@ class Client: def get_message_history(self, message_id: int) -> Dict[str, Any]: ''' - See examples/message-history for example usage. + See examples/message-history for example usage. ''' - return self.call_endpoint( - url='messages/{}/history'.format(message_id), - method='GET' - ) + return self.call_endpoint(url='messages/{}/history'.format(message_id), method='GET') def add_reaction(self, reaction_data: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.add_reaction({ - 'message_id': 100, - 'emoji_name': 'joy', - 'emoji_code': '1f602', - 'reaction_type': 'unicode_emoji' - }) - {'result': 'success', 'msg': ''} + >>> client.add_reaction({ + 'message_id': 100, + 'emoji_name': 'joy', + 'emoji_code': '1f602', + 'reaction_type': 'unicode_emoji' + }) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='messages/{}/reactions'.format(reaction_data['message_id']), @@ -863,15 +937,15 @@ class Client: def remove_reaction(self, reaction_data: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.remove_reaction({ - 'message_id': 100, - 'emoji_name': 'joy', - 'emoji_code': '1f602', - 'reaction_type': 'unicode_emoji' - }) - {'msg': '', 'result': 'success'} + >>> client.remove_reaction({ + 'message_id': 100, + 'emoji_name': 'joy', + 'emoji_code': '1f602', + 'reaction_type': 'unicode_emoji' + }) + {'msg': '', 'result': 'success'} ''' return self.call_endpoint( url='messages/{}/reactions'.format(reaction_data['message_id']), @@ -881,32 +955,27 @@ class Client: def get_realm_emoji(self) -> Dict[str, Any]: ''' - See examples/realm-emoji for example usage. + See examples/realm-emoji for example usage. ''' - return self.call_endpoint( - url='realm/emoji', - method='GET' - ) + return self.call_endpoint(url='realm/emoji', method='GET') def upload_custom_emoji(self, emoji_name: str, file_obj: IO[Any]) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.upload_custom_emoji(emoji_name, file_obj) - {'result': 'success', 'msg': ''} + >>> client.upload_custom_emoji(emoji_name, file_obj) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( - 'realm/emoji/{}'.format(emoji_name), - method='POST', - files=[file_obj] + 'realm/emoji/{}'.format(emoji_name), method='POST', files=[file_obj] ) def delete_custom_emoji(self, emoji_name: str) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.delete_custom_emoji("green_tick") - {'result': 'success', 'msg': ''} + >>> client.delete_custom_emoji("green_tick") + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='realm/emoji/{}'.format(emoji_name), @@ -915,20 +984,20 @@ class Client: def get_realm_linkifiers(self) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_realm_linkifiers() - { - 'result': 'success', - 'msg': '', - 'linkifiers': [ - { - 'id': 1, - 'pattern': #(?P[0-9]+)', - 'url_format': 'https://github.com/zulip/zulip/issues/%(id)s', - }, - ] - } + >>> client.get_realm_linkifiers() + { + 'result': 'success', + 'msg': '', + 'linkifiers': [ + { + 'id': 1, + 'pattern': #(?P[0-9]+)', + 'url_format': 'https://github.com/zulip/zulip/issues/%(id)s', + }, + ] + } ''' return self.call_endpoint( url='realm/linkifiers', @@ -937,10 +1006,10 @@ class Client: def add_realm_filter(self, pattern: str, url_format_string: str) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.add_realm_filter('#(?P[0-9]+)', 'https://github.com/zulip/zulip/issues/%(id)s') - {'result': 'success', 'msg': '', 'id': 42} + >>> client.add_realm_filter('#(?P[0-9]+)', 'https://github.com/zulip/zulip/issues/%(id)s') + {'result': 'success', 'msg': '', 'id': 42} ''' return self.call_endpoint( url='realm/filters', @@ -953,10 +1022,10 @@ class Client: def remove_realm_filter(self, filter_id: int) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.remove_realm_filter(42) - {'result': 'success', 'msg': ''} + >>> client.remove_realm_filter(42) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='realm/filters/{}'.format(filter_id), @@ -965,10 +1034,10 @@ class Client: def get_realm_profile_fields(self) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_realm_profile_fields() - {'result': 'success', 'msg': '', 'custom_fields': [{...}, {...}, {...}, {...}]} + >>> client.get_realm_profile_fields() + {'result': 'success', 'msg': '', 'custom_fields': [{...}, {...}, {...}, {...}]} ''' return self.call_endpoint( url='realm/profile_fields', @@ -977,10 +1046,10 @@ class Client: def create_realm_profile_field(self, **request: Any) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.create_realm_profile_field(name='Phone', hint='Contact No.', field_type=1) - {'result': 'success', 'msg': '', 'id': 9} + >>> client.create_realm_profile_field(name='Phone', hint='Contact No.', field_type=1) + {'result': 'success', 'msg': '', 'id': 9} ''' return self.call_endpoint( url='realm/profile_fields', @@ -990,10 +1059,10 @@ class Client: def remove_realm_profile_field(self, field_id: int) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.remove_realm_profile_field(field_id=9) - {'result': 'success', 'msg': ''} + >>> client.remove_realm_profile_field(field_id=9) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='realm/profile_fields/{}'.format(field_id), @@ -1002,10 +1071,10 @@ class Client: def reorder_realm_profile_fields(self, **request: Any) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.reorder_realm_profile_fields(order=[8, 7, 6, 5, 4, 3, 2, 1]) - {'result': 'success', 'msg': ''} + >>> client.reorder_realm_profile_fields(order=[8, 7, 6, 5, 4, 3, 2, 1]) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='realm/profile_fields', @@ -1015,10 +1084,10 @@ class Client: def update_realm_profile_field(self, field_id: int, **request: Any) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_realm_profile_field(field_id=1, name='Email') - {'result': 'success', 'msg': ''} + >>> client.update_realm_profile_field(field_id=1, name='Email') + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='realm/profile_fields/{}'.format(field_id), @@ -1028,10 +1097,10 @@ class Client: def get_server_settings(self) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_server_settings() - {'msg': '', 'result': 'success', 'zulip_version': '1.9.0', 'push_notifications_enabled': False, ...} + >>> client.get_server_settings() + {'msg': '', 'result': 'success', 'zulip_version': '1.9.0', 'push_notifications_enabled': False, ...} ''' return self.call_endpoint( url='server_settings', @@ -1040,7 +1109,7 @@ class Client: def get_events(self, **request: Any) -> Dict[str, Any]: ''' - See the register() method for example usage. + See the register() method for example usage. ''' return self.call_endpoint( url='events', @@ -1053,25 +1122,21 @@ class Client: self, event_types: Optional[Iterable[str]] = None, narrow: Optional[List[List[str]]] = None, - **kwargs: object + **kwargs: object, ) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.register(['message']) - {u'msg': u'', u'max_message_id': 112, u'last_event_id': -1, u'result': u'success', u'queue_id': u'1482093786:2'} - >>> client.get_events(queue_id='1482093786:2', last_event_id=0) - {...} + >>> client.register(['message']) + {u'msg': u'', u'max_message_id': 112, u'last_event_id': -1, u'result': u'success', u'queue_id': u'1482093786:2'} + >>> client.get_events(queue_id='1482093786:2', last_event_id=0) + {...} ''' if narrow is None: narrow = [] - request = dict( - event_types=event_types, - narrow=narrow, - **kwargs - ) + request = dict(event_types=event_types, narrow=narrow, **kwargs) return self.call_endpoint( url='register', @@ -1080,12 +1145,12 @@ class Client: def deregister(self, queue_id: str, timeout: Optional[float] = None) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.register(['message']) - {u'msg': u'', u'max_message_id': 113, u'last_event_id': -1, u'result': u'success', u'queue_id': u'1482093786:3'} - >>> client.deregister('1482093786:3') - {u'msg': u'', u'result': u'success'} + >>> client.register(['message']) + {u'msg': u'', u'max_message_id': 113, u'last_event_id': -1, u'result': u'success', u'queue_id': u'1482093786:3'} + >>> client.deregister('1482093786:3') + {u'msg': u'', u'result': u'success'} ''' request = dict(queue_id=queue_id) @@ -1098,10 +1163,10 @@ class Client: def get_profile(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_profile() - {u'user_id': 5, u'full_name': u'Iago', u'short_name': u'iago', ...} + >>> client.get_profile() + {u'user_id': 5, u'full_name': u'Iago', u'short_name': u'iago', ...} ''' return self.call_endpoint( url='users/me', @@ -1111,10 +1176,10 @@ class Client: def get_user_presence(self, email: str) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_user_presence('iago@zulip.com') - {'presence': {'website': {'timestamp': 1486799122, 'status': 'active'}}, 'result': 'success', 'msg': ''} + >>> client.get_user_presence('iago@zulip.com') + {'presence': {'website': {'timestamp': 1486799122, 'status': 'active'}}, 'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='users/%s/presence' % (email,), @@ -1123,10 +1188,10 @@ class Client: def get_realm_presence(self) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_realm_presence() - {'presences': {...}, 'result': 'success', 'msg': ''} + >>> client.get_realm_presence() + {'presences': {...}, 'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='realm/presence', @@ -1135,14 +1200,14 @@ class Client: def update_presence(self, request: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_presence({ - status='active', - ping_only=False, - new_user_input=False, - }) - {'result': 'success', 'server_timestamp': 1333649180.7073195, 'presences': {'iago@zulip.com': { ... }}, 'msg': ''} + >>> client.update_presence({ + status='active', + ping_only=False, + new_user_input=False, + }) + {'result': 'success', 'server_timestamp': 1333649180.7073195, 'presences': {'iago@zulip.com': { ... }}, 'msg': ''} ''' return self.call_endpoint( url='users/me/presence', @@ -1152,7 +1217,7 @@ class Client: def get_streams(self, **request: Any) -> Dict[str, Any]: ''' - See examples/get-public-streams for example usage. + See examples/get-public-streams for example usage. ''' return self.call_endpoint( url='streams', @@ -1162,7 +1227,7 @@ class Client: def update_stream(self, stream_data: Dict[str, Any]) -> Dict[str, Any]: ''' - See examples/edit-stream for example usage. + See examples/edit-stream for example usage. ''' return self.call_endpoint( @@ -1173,7 +1238,7 @@ class Client: def delete_stream(self, stream_id: int) -> Dict[str, Any]: ''' - See examples/delete-stream for example usage. + See examples/delete-stream for example usage. ''' return self.call_endpoint( url='streams/{}'.format(stream_id), @@ -1183,10 +1248,10 @@ class Client: def add_default_stream(self, stream_id: int) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.add_default_stream(5) - {'result': 'success', 'msg': ''} + >>> client.add_default_stream(5) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='default_streams', @@ -1197,10 +1262,10 @@ class Client: def get_user_by_id(self, user_id: int, **request: Any) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_user_by_id(8, include_custom_profile_fields=True) - {'result': 'success', 'msg': '', 'user': [{...}, {...}]} + >>> client.get_user_by_id(8, include_custom_profile_fields=True) + {'result': 'success', 'msg': '', 'user': [{...}, {...}]} ''' return self.call_endpoint( url='users/{}'.format(user_id), @@ -1211,10 +1276,10 @@ class Client: def deactivate_user_by_id(self, user_id: int) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.deactivate_user_by_id(8) - {'result': 'success', 'msg': ''} + >>> client.deactivate_user_by_id(8) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='users/{}'.format(user_id), @@ -1224,10 +1289,10 @@ class Client: def reactivate_user_by_id(self, user_id: int) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.reactivate_user_by_id(8) - {'result': 'success', 'msg': ''} + >>> client.reactivate_user_by_id(8) + {'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='users/{}/reactivate'.format(user_id), @@ -1237,24 +1302,20 @@ class Client: def update_user_by_id(self, user_id: int, **request: Any) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_user_by_id(8, full_name="New Name") - {'result': 'success', 'msg': ''} + >>> client.update_user_by_id(8, full_name="New Name") + {'result': 'success', 'msg': ''} ''' for key, value in request.items(): request[key] = json.dumps(value) - return self.call_endpoint( - url='users/{}'.format(user_id), - method='PATCH', - request=request - ) + return self.call_endpoint(url='users/{}'.format(user_id), method='PATCH', request=request) def get_users(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ''' - See examples/list-users for example usage. + See examples/list-users for example usage. ''' return self.call_endpoint( url='users', @@ -1271,40 +1332,29 @@ class Client: def get_alert_words(self) -> Dict[str, Any]: ''' - See examples/alert-words for example usage. + See examples/alert-words for example usage. ''' - return self.call_endpoint( - url='users/me/alert_words', - method='GET' - ) + return self.call_endpoint(url='users/me/alert_words', method='GET') def add_alert_words(self, alert_words: List[str]) -> Dict[str, Any]: ''' - See examples/alert-words for example usage. + See examples/alert-words for example usage. ''' return self.call_endpoint( - url='users/me/alert_words', - method='POST', - request={ - 'alert_words': alert_words - } + url='users/me/alert_words', method='POST', request={'alert_words': alert_words} ) def remove_alert_words(self, alert_words: List[str]) -> Dict[str, Any]: ''' - See examples/alert-words for example usage. + See examples/alert-words for example usage. ''' return self.call_endpoint( - url='users/me/alert_words', - method='DELETE', - request={ - 'alert_words': alert_words - } + url='users/me/alert_words', method='DELETE', request={'alert_words': alert_words} ) def get_subscriptions(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ''' - See examples/get-subscriptions for example usage. + See examples/get-subscriptions for example usage. ''' return self.call_endpoint( url='users/me/subscriptions', @@ -1313,33 +1363,29 @@ class Client: ) def list_subscriptions(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - logger.warning("list_subscriptions() is deprecated." - " Please use get_subscriptions() instead.") + logger.warning( + "list_subscriptions() is deprecated." " Please use get_subscriptions() instead." + ) return self.get_subscriptions(request) def add_subscriptions(self, streams: Iterable[Dict[str, Any]], **kwargs: Any) -> Dict[str, Any]: ''' - See examples/subscribe for example usage. + See examples/subscribe for example usage. ''' - request = dict( - subscriptions=streams, - **kwargs - ) + request = dict(subscriptions=streams, **kwargs) return self.call_endpoint( url='users/me/subscriptions', request=request, ) - def remove_subscriptions(self, streams: Iterable[str], - principals: Union[Sequence[str], Sequence[int]] = []) -> Dict[str, Any]: + def remove_subscriptions( + self, streams: Iterable[str], principals: Union[Sequence[str], Sequence[int]] = [] + ) -> Dict[str, Any]: ''' - See examples/unsubscribe for example usage. + See examples/unsubscribe for example usage. ''' - request = dict( - subscriptions=streams, - principals=principals - ) + request = dict(subscriptions=streams, principals=principals) return self.call_endpoint( url='users/me/subscriptions', method='DELETE', @@ -1348,10 +1394,10 @@ class Client: def get_subscription_status(self, user_id: int, stream_id: int) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.get_subscription_status(user_id=7, stream_id=1) - {'result': 'success', 'msg': '', 'is_subscribed': False} + >>> client.get_subscription_status(user_id=7, stream_id=1) + {'result': 'success', 'msg': '', 'is_subscribed': False} ''' return self.call_endpoint( url='users/{}/subscriptions/{}'.format(user_id, stream_id), @@ -1360,45 +1406,45 @@ class Client: def mute_topic(self, request: Dict[str, Any]) -> Dict[str, Any]: ''' - See examples/mute-topic for example usage. + See examples/mute-topic for example usage. ''' return self.call_endpoint( - url='users/me/subscriptions/muted_topics', - method='PATCH', - request=request + url='users/me/subscriptions/muted_topics', method='PATCH', request=request ) - def update_subscription_settings(self, subscription_data: List[Dict[str, Any]]) -> Dict[str, Any]: + def update_subscription_settings( + self, subscription_data: List[Dict[str, Any]] + ) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_subscription_settings([{ - 'stream_id': 1, - 'property': 'pin_to_top', - 'value': True - }, - { - 'stream_id': 3, - 'property': 'color', - 'value': 'f00' - }]) - {'result': 'success', 'msg': '', 'subscription_data': [{...}, {...}]} + >>> client.update_subscription_settings([{ + 'stream_id': 1, + 'property': 'pin_to_top', + 'value': True + }, + { + 'stream_id': 3, + 'property': 'color', + 'value': 'f00' + }]) + {'result': 'success', 'msg': '', 'subscription_data': [{...}, {...}]} ''' return self.call_endpoint( url='users/me/subscriptions/properties', method='POST', - request={'subscription_data': subscription_data} + request={'subscription_data': subscription_data}, ) def update_notification_settings(self, notification_settings: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_notification_settings({ - 'enable_stream_push_notifications': True, - 'enable_offline_push_notifications': False, - }) - {'enable_offline_push_notifications': False, 'enable_stream_push_notifications': True, 'msg': '', 'result': 'success'} + >>> client.update_notification_settings({ + 'enable_stream_push_notifications': True, + 'enable_offline_push_notifications': False, + }) + {'enable_offline_push_notifications': False, 'enable_stream_push_notifications': True, 'msg': '', 'result': 'success'} ''' return self.call_endpoint( url='settings/notifications', @@ -1408,7 +1454,7 @@ class Client: def get_stream_id(self, stream: str) -> Dict[str, Any]: ''' - Example usage: client.get_stream_id('devel') + Example usage: client.get_stream_id('devel') ''' stream_encoded = urllib.parse.quote(stream, safe='') url = 'get_stream_id?stream=%s' % (stream_encoded,) @@ -1420,18 +1466,15 @@ class Client: def get_stream_topics(self, stream_id: int) -> Dict[str, Any]: ''' - See examples/get-stream-topics for example usage. + See examples/get-stream-topics for example usage. ''' - return self.call_endpoint( - url='users/me/{}/topics'.format(stream_id), - method='GET' - ) + return self.call_endpoint(url='users/me/{}/topics'.format(stream_id), method='GET') def get_user_groups(self) -> Dict[str, Any]: ''' - Example usage: - >>> client.get_user_groups() - {'result': 'success', 'msg': '', 'user_groups': [{...}, {...}]} + Example usage: + >>> client.get_user_groups() + {'result': 'success', 'msg': '', 'user_groups': [{...}, {...}]} ''' return self.call_endpoint( url='user_groups', @@ -1440,13 +1483,13 @@ class Client: def create_user_group(self, group_data: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: - >>> client.create_user_group({ - 'name': 'marketing', - 'description': "Members of ACME Corp.'s marketing team.", - 'members': [4, 8, 15, 16, 23, 42], - }) - {'msg': '', 'result': 'success'} + Example usage: + >>> client.create_user_group({ + 'name': 'marketing', + 'description': "Members of ACME Corp.'s marketing team.", + 'members': [4, 8, 15, 16, 23, 42], + }) + {'msg': '', 'result': 'success'} ''' return self.call_endpoint( url='user_groups/create', @@ -1456,14 +1499,14 @@ class Client: def update_user_group(self, group_data: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_user_group({ - 'group_id': 1, - 'name': 'marketing', - 'description': "Members of ACME Corp.'s marketing team.", - }) - {'description': 'Description successfully updated.', 'name': 'Name successfully updated.', 'result': 'success', 'msg': ''} + >>> client.update_user_group({ + 'group_id': 1, + 'name': 'marketing', + 'description': "Members of ACME Corp.'s marketing team.", + }) + {'description': 'Description successfully updated.', 'name': 'Name successfully updated.', 'result': 'success', 'msg': ''} ''' return self.call_endpoint( url='user_groups/{}'.format(group_data['group_id']), @@ -1473,25 +1516,27 @@ class Client: def remove_user_group(self, group_id: int) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.remove_user_group(42) - {'msg': '', 'result': 'success'} + >>> client.remove_user_group(42) + {'msg': '', 'result': 'success'} ''' return self.call_endpoint( url='user_groups/{}'.format(group_id), method='DELETE', ) - def update_user_group_members(self, user_group_id: int, group_data: Dict[str, Any]) -> Dict[str, Any]: + def update_user_group_members( + self, user_group_id: int, group_data: Dict[str, Any] + ) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_user_group_members(1, { - 'delete': [8, 10], - 'add': [11], - }) - {'msg': '', 'result': 'success'} + >>> client.update_user_group_members(1, { + 'delete': [8, 10], + 'add': [11], + }) + {'msg': '', 'result': 'success'} ''' return self.call_endpoint( url='user_groups/{}/members'.format(user_group_id), @@ -1501,7 +1546,7 @@ class Client: def get_subscribers(self, **request: Any) -> Dict[str, Any]: ''' - Example usage: client.get_subscribers(stream='devel') + Example usage: client.get_subscribers(stream='devel') ''' response = self.get_stream_id(request['stream']) if response['result'] == 'error': @@ -1517,10 +1562,10 @@ class Client: def render_message(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.render_message(request=dict(content='foo **bar**')) - {u'msg': u'', u'rendered': u'

foo bar

', u'result': u'success'} + >>> client.render_message(request=dict(content='foo **bar**')) + {u'msg': u'', u'rendered': u'

foo bar

', u'result': u'success'} ''' return self.call_endpoint( url='messages/render', @@ -1530,7 +1575,7 @@ class Client: def create_user(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ''' - See examples/create-user for example usage. + See examples/create-user for example usage. ''' return self.call_endpoint( method='POST', @@ -1540,11 +1585,11 @@ class Client: def update_storage(self, request: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_storage({'storage': {"entry 1": "value 1", "entry 2": "value 2", "entry 3": "value 3"}}) - >>> client.get_storage({'keys': ["entry 1", "entry 3"]}) - {'result': 'success', 'storage': {'entry 1': 'value 1', 'entry 3': 'value 3'}, 'msg': ''} + >>> client.update_storage({'storage': {"entry 1": "value 1", "entry 2": "value 2", "entry 3": "value 3"}}) + >>> client.get_storage({'keys': ["entry 1", "entry 3"]}) + {'result': 'success', 'storage': {'entry 1': 'value 1', 'entry 3': 'value 3'}, 'msg': ''} ''' return self.call_endpoint( url='bot_storage', @@ -1554,13 +1599,13 @@ class Client: def get_storage(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ''' - Example usage: + Example usage: - >>> client.update_storage({'storage': {"entry 1": "value 1", "entry 2": "value 2", "entry 3": "value 3"}}) - >>> client.get_storage() - {'result': 'success', 'storage': {"entry 1": "value 1", "entry 2": "value 2", "entry 3": "value 3"}, 'msg': ''} - >>> client.get_storage({'keys': ["entry 1", "entry 3"]}) - {'result': 'success', 'storage': {'entry 1': 'value 1', 'entry 3': 'value 3'}, 'msg': ''} + >>> client.update_storage({'storage': {"entry 1": "value 1", "entry 2": "value 2", "entry 3": "value 3"}}) + >>> client.get_storage() + {'result': 'success', 'storage': {"entry 1": "value 1", "entry 2": "value 2", "entry 3": "value 3"}, 'msg': ''} + >>> client.get_storage({'keys': ["entry 1", "entry 3"]}) + {'result': 'success', 'storage': {'entry 1': 'value 1', 'entry 3': 'value 3'}, 'msg': ''} ''' return self.call_endpoint( url='bot_storage', @@ -1570,18 +1615,14 @@ class Client: def set_typing_status(self, request: Dict[str, Any]) -> Dict[str, Any]: ''' - Example usage: - >>> client.set_typing_status({ - 'op': 'start', - 'to': [9, 10], - }) - {'result': 'success', 'msg': ''} + Example usage: + >>> client.set_typing_status({ + 'op': 'start', + 'to': [9, 10], + }) + {'result': 'success', 'msg': ''} ''' - return self.call_endpoint( - url='typing', - method='POST', - request=request - ) + return self.call_endpoint(url='typing', method='POST', request=request) def move_topic( self, @@ -1592,22 +1633,22 @@ class Client: message_id: Optional[int] = None, propagate_mode: str = 'change_all', notify_old_topic: bool = True, - notify_new_topic: bool = True + notify_new_topic: bool = True, ) -> Dict[str, Any]: ''' - Move a topic from ``stream`` to ``new_stream`` + Move a topic from ``stream`` to ``new_stream`` - The topic will be renamed if ``new_topic`` is provided. - message_id and propagation_mode let you control which messages - should be moved. The default behavior moves all messages in topic. + The topic will be renamed if ``new_topic`` is provided. + message_id and propagation_mode let you control which messages + should be moved. The default behavior moves all messages in topic. - propagation_mode must be one of: `change_one`, `change_later`, - `change_all`. Defaults to `change_all`. + propagation_mode must be one of: `change_one`, `change_later`, + `change_all`. Defaults to `change_all`. - Example usage: + Example usage: - >>> client.move_topic('stream_a', 'stream_b', 'my_topic') - {'result': 'success', 'msg': ''} + >>> client.move_topic('stream_a', 'stream_b', 'my_topic') + {'result': 'success', 'msg': ''} ''' # get IDs for source and target streams result = self.get_stream_id(stream) @@ -1622,26 +1663,28 @@ class Client: if message_id is None: if propagate_mode != 'change_all': - raise AttributeError('A message_id must be provided if ' - 'propagate_mode isn\'t "change_all"') + raise AttributeError( + 'A message_id must be provided if ' 'propagate_mode isn\'t "change_all"' + ) # ask the server for the latest message ID in the topic. - result = self.get_messages({ - 'anchor': 'newest', - 'narrow': [{'operator': 'stream', 'operand': stream}, - {'operator': 'topic', 'operand': topic}], - 'num_before': 1, - 'num_after': 0, - }) + result = self.get_messages( + { + 'anchor': 'newest', + 'narrow': [ + {'operator': 'stream', 'operand': stream}, + {'operator': 'topic', 'operand': topic}, + ], + 'num_before': 1, + 'num_after': 0, + } + ) if result['result'] != 'success': return result if len(result['messages']) <= 0: - return { - 'result': 'error', - 'msg': 'No messages found in topic: "{}"'.format(topic) - } + return {'result': 'error', 'msg': 'No messages found in topic: "{}"'.format(topic)} message_id = result['messages'][0]['id'] @@ -1651,7 +1694,7 @@ class Client: 'propagate_mode': propagate_mode, 'topic': new_topic, 'send_notification_to_old_thread': notify_old_topic, - 'send_notification_to_new_thread': notify_new_topic + 'send_notification_to_new_thread': notify_new_topic, } return self.call_endpoint( url='messages/{}'.format(message_id), @@ -1672,15 +1715,13 @@ class ZulipStream: self.subject = subject def write(self, content: str) -> None: - message = {"type": self.type, - "to": self.to, - "subject": self.subject, - "content": content} + message = {"type": self.type, "to": self.to, "subject": self.subject, "content": content} self.client.send_message(message) def flush(self) -> None: pass + def hash_util_decode(string: str) -> str: """ Returns a decoded string given a hash_util_encode() [present in zulip/zulip's zerver/lib/url_encoding.py] encoded string. diff --git a/zulip/zulip/api_examples.py b/zulip/zulip/api_examples.py index 2f44334..acb3159 100644 --- a/zulip/zulip/api_examples.py +++ b/zulip/zulip/api_examples.py @@ -10,19 +10,21 @@ def main() -> None: Prints the path to the Zulip API example scripts.""" parser = argparse.ArgumentParser(usage=usage) - parser.add_argument('script_name', - nargs='?', - default='', - help='print path to the script ') + parser.add_argument( + 'script_name', nargs='?', default='', help='print path to the script ' + ) args = parser.parse_args() zulip_path = os.path.abspath(os.path.dirname(zulip.__file__)) examples_path = os.path.abspath(os.path.join(zulip_path, 'examples', args.script_name)) if os.path.isdir(examples_path) or (args.script_name and os.path.isfile(examples_path)): print(examples_path) else: - raise OSError("Examples cannot be accessed at {}: {} does not exist!" - .format(examples_path, - "File" if args.script_name else "Directory")) + raise OSError( + "Examples cannot be accessed at {}: {} does not exist!".format( + examples_path, "File" if args.script_name else "Directory" + ) + ) + if __name__ == '__main__': main() diff --git a/zulip/zulip/examples/create-user b/zulip/zulip/examples/create-user index 1ac6b9c..1da83ef 100755 --- a/zulip/zulip/examples/create-user +++ b/zulip/zulip/examples/create-user @@ -23,9 +23,13 @@ options = parser.parse_args() client = zulip.init_from_options(options) -print(client.create_user({ - 'email': options.new_email, - 'password': options.new_password, - 'full_name': options.new_full_name, - 'short_name': options.new_short_name -})) +print( + client.create_user( + { + 'email': options.new_email, + 'password': options.new_password, + 'full_name': options.new_full_name, + 'short_name': options.new_short_name, + } + ) +) diff --git a/zulip/zulip/examples/edit-stream b/zulip/zulip/examples/edit-stream index 7e88aad..8554ce5 100755 --- a/zulip/zulip/examples/edit-stream +++ b/zulip/zulip/examples/edit-stream @@ -29,11 +29,15 @@ options = parser.parse_args() client = zulip.init_from_options(options) -print(client.update_stream({ - 'stream_id': options.stream_id, - 'description': quote(options.description), - 'new_name': quote(options.new_name), - 'is_private': options.private, - 'is_announcement_only': options.announcement_only, - 'history_public_to_subscribers': options.history_public_to_subscribers -})) +print( + client.update_stream( + { + 'stream_id': options.stream_id, + 'description': quote(options.description), + 'new_name': quote(options.new_name), + 'is_private': options.private, + 'is_announcement_only': options.announcement_only, + 'history_public_to_subscribers': options.history_public_to_subscribers, + } + ) +) diff --git a/zulip/zulip/examples/get-history b/zulip/zulip/examples/get-history index 89013bb..6bdd858 100644 --- a/zulip/zulip/examples/get-history +++ b/zulip/zulip/examples/get-history @@ -15,8 +15,12 @@ Example: get-history --stream announce --topic important""" parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) parser.add_argument('--stream', required=True, help="The stream name to get the history") parser.add_argument('--topic', help="The topic name to get the history") -parser.add_argument('--filename', default='history.json', help="The file name to store the fetched \ - history.\n Default 'history.json'") +parser.add_argument( + '--filename', + default='history.json', + help="The file name to store the fetched \ + history.\n Default 'history.json'", +) options = parser.parse_args() client = zulip.init_from_options(options) @@ -33,7 +37,7 @@ request = { 'num_after': 1000, 'narrow': narrow, 'client_gravatar': False, - 'apply_markdown': False + 'apply_markdown': False, } all_messages = [] # type: List[Dict[str, Any]] diff --git a/zulip/zulip/examples/get-messages b/zulip/zulip/examples/get-messages index 0bd944e..96faa95 100755 --- a/zulip/zulip/examples/get-messages +++ b/zulip/zulip/examples/get-messages @@ -28,12 +28,16 @@ options = parser.parse_args() client = zulip.init_from_options(options) -print(client.get_messages({ - 'anchor': options.anchor, - 'use_first_unread_anchor': options.use_first_unread_anchor, - 'num_before': options.num_before, - 'num_after': options.num_after, - 'narrow': options.narrow, - 'client_gravatar': options.client_gravatar, - 'apply_markdown': options.apply_markdown -})) +print( + client.get_messages( + { + 'anchor': options.anchor, + 'use_first_unread_anchor': options.use_first_unread_anchor, + 'num_before': options.num_before, + 'num_after': options.num_after, + 'narrow': options.narrow, + 'client_gravatar': options.client_gravatar, + 'apply_markdown': options.apply_markdown, + } + ) +) diff --git a/zulip/zulip/examples/mute-topic b/zulip/zulip/examples/mute-topic index a105147..3f313ae 100755 --- a/zulip/zulip/examples/mute-topic +++ b/zulip/zulip/examples/mute-topic @@ -18,13 +18,10 @@ options = parser.parse_args() client = zulip.init_from_options(options) -OPERATIONS = { - 'mute': 'add', - 'unmute': 'remove' -} +OPERATIONS = {'mute': 'add', 'unmute': 'remove'} -print(client.mute_topic({ - 'op': OPERATIONS[options.op], - 'stream': options.stream, - 'topic': options.topic -})) +print( + client.mute_topic( + {'op': OPERATIONS[options.op], 'stream': options.stream, 'topic': options.topic} + ) +) diff --git a/zulip/zulip/examples/print-events b/zulip/zulip/examples/print-events index 5975096..28b81ee 100755 --- a/zulip/zulip/examples/print-events +++ b/zulip/zulip/examples/print-events @@ -19,9 +19,11 @@ options = parser.parse_args() client = zulip.init_from_options(options) + def print_event(event: Dict[str, Any]) -> None: print(event) + # This is a blocking call, and will continuously poll for new events # Note also the filter here is messages to the stream Denmark; if you # don't specify event_types it'll print all events. diff --git a/zulip/zulip/examples/print-messages b/zulip/zulip/examples/print-messages index d852e11..e97d1ee 100755 --- a/zulip/zulip/examples/print-messages +++ b/zulip/zulip/examples/print-messages @@ -19,8 +19,10 @@ options = parser.parse_args() client = zulip.init_from_options(options) + def print_message(message: Dict[str, Any]) -> None: print(message) + # This is a blocking call, and will continuously poll for new messages client.call_on_each_message(print_message) diff --git a/zulip/zulip/examples/subscribe b/zulip/zulip/examples/subscribe index fc3153d..07566a4 100755 --- a/zulip/zulip/examples/subscribe +++ b/zulip/zulip/examples/subscribe @@ -20,5 +20,4 @@ options = parser.parse_args() client = zulip.init_from_options(options) -print(client.add_subscriptions([{"name": stream_name} for stream_name in - options.streams.split()])) +print(client.add_subscriptions([{"name": stream_name} for stream_name in options.streams.split()])) diff --git a/zulip/zulip/examples/update-message-flags b/zulip/zulip/examples/update-message-flags index d134f62..98b8c66 100755 --- a/zulip/zulip/examples/update-message-flags +++ b/zulip/zulip/examples/update-message-flags @@ -19,8 +19,8 @@ options = parser.parse_args() client = zulip.init_from_options(options) -print(client.update_message_flags({ - 'op': options.op, - 'flag': options.flag, - 'messages': options.messages -})) +print( + client.update_message_flags( + {'op': options.op, 'flag': options.flag, 'messages': options.messages} + ) +) diff --git a/zulip/zulip/examples/upload-file b/zulip/zulip/examples/upload-file index 232bf4d..212803c 100755 --- a/zulip/zulip/examples/upload-file +++ b/zulip/zulip/examples/upload-file @@ -10,6 +10,7 @@ import zulip class StringIO(_StringIO): name = '' # https://github.com/python/typeshed/issues/598 + usage = """upload-file [options] Upload a file, and print the corresponding URI. diff --git a/zulip/zulip/examples/welcome-message b/zulip/zulip/examples/welcome-message index 8dbfb9d..73f12a1 100755 --- a/zulip/zulip/examples/welcome-message +++ b/zulip/zulip/examples/welcome-message @@ -52,13 +52,16 @@ streams_to_watch = ['new members'] # These streams will cause anyone who sends a message there to be removed from the watchlist streams_to_cancel = ['development help'] + def get_watchlist() -> List[Any]: storage = client.get_storage() return list(storage['storage'].values()) + def set_watchlist(watchlist: List[str]) -> None: client.update_storage({'storage': dict(enumerate(watchlist))}) + def handle_event(event: Dict[str, Any]) -> None: try: if event['type'] == 'realm_user' and event['op'] == 'add': @@ -74,11 +77,13 @@ def handle_event(event: Dict[str, Any]) -> None: if event['message']['sender_email'] in watchlist: watchlist.remove(event['message']['sender_email']) if stream not in streams_to_cancel: - client.send_message({ - 'type': 'private', - 'to': event['message']['sender_email'], - 'content': welcome_text.format(event['message']['sender_short_name']) - }) + client.send_message( + { + 'type': 'private', + 'to': event['message']['sender_email'], + 'content': welcome_text.format(event['message']['sender_short_name']), + } + ) set_watchlist(watchlist) return except Exception as err: @@ -89,5 +94,6 @@ def start_event_handler() -> None: print("Starting event handler...") client.call_on_each_event(handle_event, event_types=['realm_user', 'message']) + client = zulip.Client() start_event_handler() diff --git a/zulip/zulip/send.py b/zulip/zulip/send.py index 88f0f1f..ca0f281 100755 --- a/zulip/zulip/send.py +++ b/zulip/zulip/send.py @@ -12,12 +12,15 @@ logging.basicConfig() log = logging.getLogger('zulip-send') + def do_send_message(client: zulip.Client, message_data: Dict[str, Any]) -> bool: '''Sends a message and optionally prints status about the same.''' if message_data['type'] == 'stream': - log.info('Sending message to stream "%s", subject "%s"... ' % - (message_data['to'], message_data['subject'])) + log.info( + 'Sending message to stream "%s", subject "%s"... ' + % (message_data['to'], message_data['subject']) + ) else: log.info('Sending message to %s... ' % (message_data['to'],)) response = client.send_message(message_data) @@ -28,6 +31,7 @@ def do_send_message(client: zulip.Client, message_data: Dict[str, Any]) -> bool: log.error(response['msg']) return False + def main() -> int: usage = """zulip-send [options] [recipient...] @@ -41,22 +45,29 @@ def main() -> int: parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) - parser.add_argument('recipients', - nargs='*', - help='email addresses of the recipients of the message') + parser.add_argument( + 'recipients', nargs='*', help='email addresses of the recipients of the message' + ) - parser.add_argument('-m', '--message', - help='Specifies the message to send, prevents interactive prompting.') + parser.add_argument( + '-m', '--message', help='Specifies the message to send, prevents interactive prompting.' + ) group = parser.add_argument_group('Stream parameters') - group.add_argument('-s', '--stream', - dest='stream', - action='store', - help='Allows the user to specify a stream for the message.') - group.add_argument('-S', '--subject', - dest='subject', - action='store', - help='Allows the user to specify a subject for the message.') + group.add_argument( + '-s', + '--stream', + dest='stream', + action='store', + help='Allows the user to specify a stream for the message.', + ) + group.add_argument( + '-S', + '--subject', + dest='subject', + action='store', + help='Allows the user to specify a subject for the message.', + ) options = parser.parse_args() @@ -93,5 +104,6 @@ def main() -> int: return 1 return 0 + if __name__ == '__main__': sys.exit(main()) diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py index caddbc5..6f808bf 100644 --- a/zulip_bots/setup.py +++ b/zulip_bots/setup.py @@ -52,7 +52,7 @@ package_info = dict( entry_points={ 'console_scripts': [ 'zulip-run-bot=zulip_bots.run:main', - 'zulip-terminal=zulip_bots.terminal:main' + 'zulip-terminal=zulip_bots.terminal:main', ], }, include_package_data=True, @@ -71,6 +71,7 @@ setuptools_info = dict( try: from setuptools import find_packages, setup + package_info.update(setuptools_info) package_info['packages'] = find_packages() package_info['package_data'] = package_data @@ -85,11 +86,13 @@ except ImportError: try: module = import_module(module_name) # type: Any if version is not None: - assert(LooseVersion(module.__version__) >= LooseVersion(version)) + assert LooseVersion(module.__version__) >= LooseVersion(version) except (ImportError, AssertionError): if version is not None: - print("{name}>={version} is not installed.".format( - name=module_name, version=version), file=sys.stderr) + print( + "{name}>={version} is not installed.".format(name=module_name, version=version), + file=sys.stderr, + ) else: print("{name} is not installed.".format(name=module_name), file=sys.stderr) sys.exit(1) diff --git a/zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py b/zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py index 01f7db9..062b5ca 100644 --- a/zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py +++ b/zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py @@ -12,22 +12,29 @@ class BaremetricsHandler: self.config_info = bot_handler.get_config_info('baremetrics') self.api_key = self.config_info['api_key'] - self.auth_header = { - 'Authorization': 'Bearer ' + self.api_key - } + self.auth_header = {'Authorization': 'Bearer ' + self.api_key} - self.commands = ['help', - 'list-commands', - 'account-info', - 'list-sources', - 'list-plans ', - 'list-customers ', - 'list-subscriptions ', - 'create-plan '] + self.commands = [ + 'help', + 'list-commands', + 'account-info', + 'list-sources', + 'list-plans ', + 'list-customers ', + 'list-subscriptions ', + 'create-plan ', + ] - self.descriptions = ['Display bot info', 'Display the list of available commands', 'Display the account info', - 'List the sources', 'List the plans for the source', 'List the customers in the source', - 'List the subscriptions in the source', 'Create a plan in the given source'] + self.descriptions = [ + 'Display bot info', + 'Display the list of available commands', + 'Display the account info', + 'List the sources', + 'List the plans for the source', + 'List the customers in the source', + 'List the subscriptions in the source', + 'Create a plan in the given source', + ] self.check_api_key(bot_handler) @@ -114,13 +121,16 @@ class BaremetricsHandler: account_data = account_response.json() account_data = account_data['account'] - template = ['**Your account information:**', - 'Id: {id}', - 'Company: {company}', - 'Default Currency: {currency}'] + template = [ + '**Your account information:**', + 'Id: {id}', + 'Company: {company}', + 'Default Currency: {currency}', + ] - return "\n".join(template).format(currency=account_data['default_currency']['name'], - **account_data) + return "\n".join(template).format( + currency=account_data['default_currency']['name'], **account_data + ) def get_sources(self) -> str: url = 'https://api.baremetrics.com/v1/sources' @@ -131,9 +141,9 @@ class BaremetricsHandler: response = '**Listing sources:** \n' for index, source in enumerate(sources_data): - response += ('{_count}.ID: {id}\n' - 'Provider: {provider}\n' - 'Provider ID: {provider_id}\n\n').format(_count=index + 1, **source) + response += ( + '{_count}.ID: {id}\n' 'Provider: {provider}\n' 'Provider ID: {provider_id}\n\n' + ).format(_count=index + 1, **source) return response @@ -144,19 +154,20 @@ class BaremetricsHandler: plans_data = plans_response.json() plans_data = plans_data['plans'] - template = '\n'.join(['{_count}.Name: {name}', - 'Active: {active}', - 'Interval: {interval}', - 'Interval Count: {interval_count}', - 'Amounts:']) + template = '\n'.join( + [ + '{_count}.Name: {name}', + 'Active: {active}', + 'Interval: {interval}', + 'Interval Count: {interval_count}', + 'Amounts:', + ] + ) response = ['**Listing plans:**'] for index, plan in enumerate(plans_data): response += ( [template.format(_count=index + 1, **plan)] - + [ - ' - {amount} {currency}'.format(**amount) - for amount in plan['amounts'] - ] + + [' - {amount} {currency}'.format(**amount) for amount in plan['amounts']] + [''] ) @@ -170,13 +181,17 @@ class BaremetricsHandler: customers_data = customers_data['customers'] # FIXME BUG here? mismatch of name and display name? - template = '\n'.join(['{_count}.Name: {display_name}', - 'Display Name: {name}', - 'OID: {oid}', - 'Active: {is_active}', - 'Email: {email}', - 'Notes: {notes}', - 'Current Plans:']) + template = '\n'.join( + [ + '{_count}.Name: {display_name}', + 'Display Name: {name}', + 'OID: {oid}', + 'Active: {is_active}', + 'Email: {email}', + 'Notes: {notes}', + 'Current Plans:', + ] + ) response = ['**Listing customers:**'] for index, customer in enumerate(customers_data): response += ( @@ -194,13 +209,17 @@ class BaremetricsHandler: subscriptions_data = subscriptions_response.json() subscriptions_data = subscriptions_data['subscriptions'] - template = '\n'.join(['{_count}.Customer Name: {name}', - 'Customer Display Name: {display_name}', - 'Customer OID: {oid}', - 'Customer Email: {email}', - 'Active: {_active}', - 'Plan Name: {_plan_name}', - 'Plan Amounts:']) + template = '\n'.join( + [ + '{_count}.Customer Name: {name}', + 'Customer Display Name: {display_name}', + 'Customer OID: {oid}', + 'Customer Email: {email}', + 'Active: {_active}', + 'Plan Name: {_plan_name}', + 'Plan Amounts:', + ] + ) response = ['**Listing subscriptions:**'] for index, subscription in enumerate(subscriptions_data): response += ( @@ -209,7 +228,7 @@ class BaremetricsHandler: _count=index + 1, _active=subscription['active'], _plan_name=subscription['plan']['name'], - **subscription['customer'] + **subscription['customer'], ) ] + [ @@ -228,7 +247,7 @@ class BaremetricsHandler: 'currency': parameters[3], 'amount': int(parameters[4]), 'interval': parameters[5], - 'interval_count': int(parameters[6]) + 'interval_count': int(parameters[6]), } # type: Any url = 'https://api.baremetrics.com/v1/{}/plans'.format(parameters[0]) @@ -238,4 +257,5 @@ class BaremetricsHandler: else: return 'Invalid Arguments Error.' + handler_class = BaremetricsHandler diff --git a/zulip_bots/zulip_bots/bots/baremetrics/test_baremetrics.py b/zulip_bots/zulip_bots/bots/baremetrics/test_baremetrics.py index 8c7ce44..08c16bd 100644 --- a/zulip_bots/zulip_bots/bots/baremetrics/test_baremetrics.py +++ b/zulip_bots/zulip_bots/bots/baremetrics/test_baremetrics.py @@ -8,68 +8,83 @@ class TestBaremetricsBot(BotTestCase, DefaultTests): bot_name = "baremetrics" def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): self.verify_reply('', 'No Command Specified') def test_help_query(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): - self.verify_reply('help', ''' + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): + self.verify_reply( + 'help', + ''' This bot gives updates about customer behavior, financial performance, and analytics for an organization using the Baremetrics Api.\n Enter `list-commands` to show the list of available commands. Version 1.0 - ''') + ''', + ) def test_list_commands_command(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): - self.verify_reply('list-commands', '**Available Commands:** \n' - ' - help : Display bot info\n' - ' - list-commands : Display the list of available commands\n' - ' - account-info : Display the account info\n' - ' - list-sources : List the sources\n' - ' - list-plans : List the plans for the source\n' - ' - list-customers : List the customers in the source\n' - ' - list-subscriptions : List the subscriptions in the ' - 'source\n' - ' - create-plan ' - ' : Create a plan in the given source\n') + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): + self.verify_reply( + 'list-commands', + '**Available Commands:** \n' + ' - help : Display bot info\n' + ' - list-commands : Display the list of available commands\n' + ' - account-info : Display the account info\n' + ' - list-sources : List the sources\n' + ' - list-plans : List the plans for the source\n' + ' - list-customers : List the customers in the source\n' + ' - list-subscriptions : List the subscriptions in the ' + 'source\n' + ' - create-plan ' + ' : Create a plan in the given source\n', + ) def test_account_info_command(self) -> None: with self.mock_config_info({'api_key': 'TEST'}): with self.mock_http_conversation('account_info'): - self.verify_reply('account-info', '**Your account information:**\nId: 376418\nCompany: NA\nDefault ' - 'Currency: United States Dollar') + self.verify_reply( + 'account-info', + '**Your account information:**\nId: 376418\nCompany: NA\nDefault ' + 'Currency: United States Dollar', + ) def test_list_sources_command(self) -> None: with self.mock_config_info({'api_key': 'TEST'}): with self.mock_http_conversation('list_sources'): - self.verify_reply('list-sources', '**Listing sources:** \n1.ID: 5f7QC5NC0Ywgcu\nProvider: ' - 'baremetrics\nProvider ID: None\n\n') + self.verify_reply( + 'list-sources', + '**Listing sources:** \n1.ID: 5f7QC5NC0Ywgcu\nProvider: ' + 'baremetrics\nProvider ID: None\n\n', + ) def test_list_plans_command(self) -> None: - r = '**Listing plans:**\n1.Name: Plan 1\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n' \ - ' - 450000 USD\n\n2.Name: Plan 2\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n' \ + r = ( + '**Listing plans:**\n1.Name: Plan 1\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n' + ' - 450000 USD\n\n2.Name: Plan 2\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n' ' - 450000 USD\n' + ) with self.mock_config_info({'api_key': 'TEST'}): with self.mock_http_conversation('list_plans'): self.verify_reply('list-plans TEST', r) def test_list_customers_command(self) -> None: - r = '**Listing customers:**\n1.Name: Customer 1\nDisplay Name: Customer 1\nOID: customer_1\nActive: True\n' \ + r = ( + '**Listing customers:**\n1.Name: Customer 1\nDisplay Name: Customer 1\nOID: customer_1\nActive: True\n' 'Email: customer_1@baremetrics.com\nNotes: Here are some notes\nCurrent Plans:\n - Plan 1\n' + ) with self.mock_config_info({'api_key': 'TEST'}): with self.mock_http_conversation('list_customers'): self.verify_reply('list-customers TEST', r) def test_list_subscriptions_command(self) -> None: - r = '**Listing subscriptions:**\n1.Customer Name: Customer 1\nCustomer Display Name: Customer 1\n' \ - 'Customer OID: customer_1\nCustomer Email: customer_1@baremetrics.com\nActive: True\n' \ + r = ( + '**Listing subscriptions:**\n1.Customer Name: Customer 1\nCustomer Display Name: Customer 1\n' + 'Customer OID: customer_1\nCustomer Email: customer_1@baremetrics.com\nActive: True\n' 'Plan Name: Plan 1\nPlan Amounts:\n - 1000 $\n' + ) with self.mock_config_info({'api_key': 'TEST'}): with self.mock_http_conversation('list_subscriptions'): @@ -84,34 +99,30 @@ class TestBaremetricsBot(BotTestCase, DefaultTests): bot_test_instance.initialize(StubBotHandler()) def test_invalid_command(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): self.verify_reply('abcd', 'Invalid Command.') def test_missing_params(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): self.verify_reply('list-plans', 'Missing Params.') def test_key_error(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): with self.mock_http_conversation('test_key_error'): self.verify_reply('list-plans TEST', 'Invalid Response From API.') def test_create_plan_command(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): with self.mock_http_conversation('create_plan'): self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Plan Created.') def test_create_plan_error_command(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): with self.mock_http_conversation('create_plan_error'): - self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Invalid Arguments Error.') + self.verify_reply( + 'create-plan TEST 1 TEST USD 123 TEST 123', 'Invalid Arguments Error.' + ) def test_create_plan_argnum_error_command(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): + with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'): self.verify_reply('create-plan alpha beta', 'Invalid number of arguments.') diff --git a/zulip_bots/zulip_bots/bots/beeminder/beeminder.py b/zulip_bots/zulip_bots/bots/beeminder/beeminder.py index 7efe852..bddadd3 100644 --- a/zulip_bots/zulip_bots/bots/beeminder/beeminder.py +++ b/zulip_bots/zulip_bots/bots/beeminder/beeminder.py @@ -16,6 +16,7 @@ following the syntax shown below :smile:.\n \ \n* `comment`**:** Add a comment [**NOTE:** Optional field, default is *None*]\ ''' + def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> str: username = config_info['username'] goalname = config_info['goalname'] @@ -25,37 +26,36 @@ def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> if message_content == '' or message_content == 'help': return help_message - url = "https://www.beeminder.com/api/v1/users/{}/goals/{}/datapoints.json".format(username, goalname) + url = "https://www.beeminder.com/api/v1/users/{}/goals/{}/datapoints.json".format( + username, goalname + ) message_pieces = message_content.split(',') for i in range(len(message_pieces)): message_pieces[i] = message_pieces[i].strip() - if (len(message_pieces) == 1): - payload = { - "value": message_pieces[0], - "auth_token": auth_token - } - elif (len(message_pieces) == 2): - if (message_pieces[1].isdigit()): + if len(message_pieces) == 1: + payload = {"value": message_pieces[0], "auth_token": auth_token} + elif len(message_pieces) == 2: + if message_pieces[1].isdigit(): payload = { "daystamp": message_pieces[0], "value": message_pieces[1], - "auth_token": auth_token + "auth_token": auth_token, } else: payload = { "value": message_pieces[0], "comment": message_pieces[1], - "auth_token": auth_token + "auth_token": auth_token, } - elif (len(message_pieces) == 3): + elif len(message_pieces) == 3: payload = { "daystamp": message_pieces[0], "value": message_pieces[1], "comment": message_pieces[2], - "auth_token": auth_token + "auth_token": auth_token, } - elif (len(message_pieces) > 3): + elif len(message_pieces) > 3: return "Make sure you follow the syntax.\n You can take a look \ at syntax by: @mention-botname help" @@ -63,13 +63,17 @@ at syntax by: @mention-botname help" r = requests.post(url, json=payload) if r.status_code != 200: - if r.status_code == 401: # Handles case of invalid key and missing key + if r.status_code == 401: # Handles case of invalid key and missing key return "Error. Check your key!" else: - return "Error occured : {}".format(r.status_code) # Occures in case of unprocessable entity + return "Error occured : {}".format( + r.status_code + ) # Occures in case of unprocessable entity else: datapoint_link = "https://www.beeminder.com/{}/{}".format(username, goalname) - return "[Datapoint]({}) created.".format(datapoint_link) # Handles the case of successful datapoint creation + return "[Datapoint]({}) created.".format( + datapoint_link + ) # Handles the case of successful datapoint creation except ConnectionError as e: logging.exception(str(e)) return "Uh-Oh, couldn't process the request \ @@ -87,7 +91,9 @@ class BeeminderHandler: # Check for valid auth_token auth_token = self.config_info['auth_token'] try: - r = requests.get("https://www.beeminder.com/api/v1/users/me.json", params={'auth_token': auth_token}) + r = requests.get( + "https://www.beeminder.com/api/v1/users/me.json", params={'auth_token': auth_token} + ) if r.status_code == 401: bot_handler.quit('Invalid key!') except ConnectionError as e: @@ -100,4 +106,5 @@ class BeeminderHandler: response = get_beeminder_response(message['content'], self.config_info) bot_handler.send_reply(message, response) + handler_class = BeeminderHandler diff --git a/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py b/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py index 3f5497c..a8de2c3 100644 --- a/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py +++ b/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py @@ -7,11 +7,7 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_b class TestBeeminderBot(BotTestCase, DefaultTests): bot_name = "beeminder" - normal_config = { - "auth_token": "XXXXXX", - "username": "aaron", - "goalname": "goal" - } + normal_config = {"auth_token": "XXXXXX", "username": "aaron", "goalname": "goal"} help_message = ''' You can add datapoints towards your beeminder goals \ @@ -24,88 +20,94 @@ following the syntax shown below :smile:.\n \ ''' def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_valid_auth_token'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_valid_auth_token' + ): self.verify_reply('', self.help_message) def test_help_message(self) -> None: - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_valid_auth_token'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_valid_auth_token' + ): self.verify_reply('help', self.help_message) def test_message_with_daystamp_and_value(self) -> None: bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.' - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_valid_auth_token'), \ - self.mock_http_conversation('test_message_with_daystamp_and_value'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_valid_auth_token' + ), self.mock_http_conversation('test_message_with_daystamp_and_value'): self.verify_reply('20180602, 2', bot_response) def test_message_with_value_and_comment(self) -> None: bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.' - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_valid_auth_token'), \ - self.mock_http_conversation('test_message_with_value_and_comment'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_valid_auth_token' + ), self.mock_http_conversation('test_message_with_value_and_comment'): self.verify_reply('2, hi there!', bot_response) def test_message_with_daystamp_and_value_and_comment(self) -> None: bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.' - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_valid_auth_token'), \ - self.mock_http_conversation('test_message_with_daystamp_and_value_and_comment'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_valid_auth_token' + ), self.mock_http_conversation('test_message_with_daystamp_and_value_and_comment'): self.verify_reply('20180602, 2, hi there!', bot_response) def test_syntax_error(self) -> None: - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_valid_auth_token'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_valid_auth_token' + ): bot_response = "Make sure you follow the syntax.\n You can take a look \ at syntax by: @mention-botname help" self.verify_reply("20180303, 50, comment, redundant comment", bot_response) def test_connection_error_when_handle_message(self) -> None: - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_valid_auth_token'), \ - patch('requests.post', side_effect=ConnectionError()), \ - patch('logging.exception'): - self.verify_reply('?$!', 'Uh-Oh, couldn\'t process the request \ -right now.\nPlease try again later') + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_valid_auth_token' + ), patch('requests.post', side_effect=ConnectionError()), patch('logging.exception'): + self.verify_reply( + '?$!', + 'Uh-Oh, couldn\'t process the request \ +right now.\nPlease try again later', + ) def test_invalid_when_handle_message(self) -> None: get_bot_message_handler(self.bot_name) StubBotHandler() - with self.mock_config_info({'auth_token': 'someInvalidKey', - 'username': 'aaron', - 'goalname': 'goal'}), \ - patch('requests.get', side_effect=ConnectionError()), \ - self.mock_http_conversation('test_invalid_when_handle_message'), \ - patch('logging.exception'): + with self.mock_config_info( + {'auth_token': 'someInvalidKey', 'username': 'aaron', 'goalname': 'goal'} + ), patch('requests.get', side_effect=ConnectionError()), self.mock_http_conversation( + 'test_invalid_when_handle_message' + ), patch( + 'logging.exception' + ): self.verify_reply('5', 'Error. Check your key!') def test_error(self) -> None: bot_request = 'notNumber' bot_response = "Error occured : 422" - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_valid_auth_token'), \ - self.mock_http_conversation('test_error'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_valid_auth_token' + ), self.mock_http_conversation('test_error'): self.verify_reply(bot_request, bot_response) def test_invalid_when_initialize(self) -> None: bot = get_bot_message_handler(self.bot_name) bot_handler = StubBotHandler() - with self.mock_config_info({'auth_token': 'someInvalidKey', - 'username': 'aaron', - 'goalname': 'goal'}), \ - self.mock_http_conversation('test_invalid_when_initialize'), \ - self.assertRaises(bot_handler.BotQuitException): + with self.mock_config_info( + {'auth_token': 'someInvalidKey', 'username': 'aaron', 'goalname': 'goal'} + ), self.mock_http_conversation('test_invalid_when_initialize'), self.assertRaises( + bot_handler.BotQuitException + ): bot.initialize(bot_handler) def test_connection_error_during_initialize(self) -> None: bot = get_bot_message_handler(self.bot_name) bot_handler = StubBotHandler() - with self.mock_config_info(self.normal_config), \ - patch('requests.get', side_effect=ConnectionError()), \ - patch('logging.exception') as mock_logging: + with self.mock_config_info(self.normal_config), patch( + 'requests.get', side_effect=ConnectionError() + ), patch('logging.exception') as mock_logging: bot.initialize(bot_handler) self.assertTrue(mock_logging.called) diff --git a/zulip_bots/zulip_bots/bots/chessbot/chessbot.py b/zulip_bots/zulip_bots/bots/chessbot/chessbot.py index 24bbd55..938ba75 100644 --- a/zulip_bots/zulip_bots/bots/chessbot/chessbot.py +++ b/zulip_bots/zulip_bots/bots/chessbot/chessbot.py @@ -8,12 +8,11 @@ import chess.uci from zulip_bots.lib import BotHandler START_REGEX = re.compile('start with other user$') -START_COMPUTER_REGEX = re.compile( - 'start as (?Pwhite|black) with computer' -) +START_COMPUTER_REGEX = re.compile('start as (?Pwhite|black) with computer') MOVE_REGEX = re.compile('do (?P.+)$') RESIGN_REGEX = re.compile('resign$') + class ChessHandler: def usage(self) -> str: return ( @@ -29,20 +28,14 @@ class ChessHandler: self.config_info = bot_handler.get_config_info('chess') try: - self.engine = chess.uci.popen_engine( - self.config_info['stockfish_location'] - ) + self.engine = chess.uci.popen_engine(self.config_info['stockfish_location']) self.engine.uci() except FileNotFoundError: # It is helpful to allow for fake Stockfish locations if the bot # runner is testing or knows they won't be using an engine. print('That Stockfish doesn\'t exist. Continuing.') - def handle_message( - self, - message: Dict[str, str], - bot_handler: BotHandler - ) -> None: + def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: content = message['content'] if content == '': @@ -60,7 +53,8 @@ class ChessHandler: if bot_handler.storage.contains('is_with_computer'): is_with_computer = ( # `bot_handler`'s `storage` only accepts `str` values. - bot_handler.storage.get('is_with_computer') == str(True) + bot_handler.storage.get('is_with_computer') + == str(True) ) if bot_handler.storage.contains('last_fen'): @@ -70,31 +64,17 @@ class ChessHandler: self.start(message, bot_handler) elif start_computer_regex_match: self.start_computer( - message, - bot_handler, - start_computer_regex_match.group('user_color') == 'white' + message, bot_handler, start_computer_regex_match.group('user_color') == 'white' ) elif move_regex_match: if is_with_computer: self.move_computer( - message, - bot_handler, - last_fen, - move_regex_match.group('move_san') + message, bot_handler, last_fen, move_regex_match.group('move_san') ) else: - self.move( - message, - bot_handler, - last_fen, - move_regex_match.group('move_san') - ) + self.move(message, bot_handler, last_fen, move_regex_match.group('move_san')) elif resign_regex_match: - self.resign( - message, - bot_handler, - last_fen - ) + self.resign(message, bot_handler, last_fen) def start(self, message: Dict[str, str], bot_handler: BotHandler) -> None: """Starts a game with another user, with the current user as white. @@ -105,10 +85,7 @@ class ChessHandler: - bot_handler: The Zulip Bots bot handler object. """ new_board = chess.Board() - bot_handler.send_reply( - message, - make_start_reponse(new_board) - ) + bot_handler.send_reply(message, make_start_reponse(new_board)) # `bot_handler`'s `storage` only accepts `str` values. bot_handler.storage.put('is_with_computer', str(False)) @@ -116,10 +93,7 @@ class ChessHandler: bot_handler.storage.put('last_fen', new_board.fen()) def start_computer( - self, - message: Dict[str, str], - bot_handler: BotHandler, - is_white_user: bool + self, message: Dict[str, str], bot_handler: BotHandler, is_white_user: bool ) -> None: """Starts a game with the computer. Replies to the bot handler. @@ -135,10 +109,7 @@ class ChessHandler: new_board = chess.Board() if is_white_user: - bot_handler.send_reply( - message, - make_start_computer_reponse(new_board) - ) + bot_handler.send_reply(message, make_start_computer_reponse(new_board)) # `bot_handler`'s `storage` only accepts `str` values. bot_handler.storage.put('is_with_computer', str(True)) @@ -152,10 +123,7 @@ class ChessHandler: ) def validate_board( - self, - message: Dict[str, str], - bot_handler: BotHandler, - fen: str + self, message: Dict[str, str], bot_handler: BotHandler, fen: str ) -> Optional[chess.Board]: """Validates a board based on its FEN string. Replies to the bot handler if there is an error with the board. @@ -171,10 +139,7 @@ class ChessHandler: try: last_board = chess.Board(fen) except ValueError: - bot_handler.send_reply( - message, - make_copied_wrong_response() - ) + bot_handler.send_reply(message, make_copied_wrong_response()) return None return last_board @@ -185,7 +150,7 @@ class ChessHandler: bot_handler: BotHandler, last_board: chess.Board, move_san: str, - is_computer: object + is_computer: object, ) -> Optional[chess.Move]: """Validates a move based on its SAN string and the current board. Replies to the bot handler if there is an error with the move. @@ -205,29 +170,17 @@ class ChessHandler: try: move = last_board.parse_san(move_san) except ValueError: - bot_handler.send_reply( - message, - make_not_legal_response( - last_board, - move_san - ) - ) + bot_handler.send_reply(message, make_not_legal_response(last_board, move_san)) return None if move not in last_board.legal_moves: - bot_handler.send_reply( - message, - make_not_legal_response(last_board, move_san) - ) + bot_handler.send_reply(message, make_not_legal_response(last_board, move_san)) return None return move def check_game_over( - self, - message: Dict[str, str], - bot_handler: BotHandler, - new_board: chess.Board + self, message: Dict[str, str], bot_handler: BotHandler, new_board: chess.Board ) -> bool: """Checks if a game is over due to - checkmate, @@ -254,38 +207,24 @@ class ChessHandler: game_over_output = '' if new_board.is_checkmate(): - game_over_output = make_loss_response( - new_board, - 'was checkmated' - ) + game_over_output = make_loss_response(new_board, 'was checkmated') elif new_board.is_stalemate(): game_over_output = make_draw_response('stalemate') elif new_board.is_insufficient_material(): - game_over_output = make_draw_response( - 'insufficient material' - ) + game_over_output = make_draw_response('insufficient material') elif new_board.can_claim_fifty_moves(): - game_over_output = make_draw_response( - '50 moves without a capture or pawn move' - ) + game_over_output = make_draw_response('50 moves without a capture or pawn move') elif new_board.can_claim_threefold_repetition(): game_over_output = make_draw_response('3-fold repetition') - bot_handler.send_reply( - message, - game_over_output - ) + bot_handler.send_reply(message, game_over_output) return True return False def move( - self, - message: Dict[str, str], - bot_handler: BotHandler, - last_fen: str, - move_san: str + self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str, move_san: str ) -> None: """Makes a move for a user in a game with another user. Replies to the bot handler. @@ -301,13 +240,7 @@ class ChessHandler: if not last_board: return - move = self.validate_move( - message, - bot_handler, - last_board, - move_san, - False - ) + move = self.validate_move(message, bot_handler, last_board, move_san, False) if not move: return @@ -318,19 +251,12 @@ class ChessHandler: if self.check_game_over(message, bot_handler, new_board): return - bot_handler.send_reply( - message, - make_move_reponse(last_board, new_board, move) - ) + bot_handler.send_reply(message, make_move_reponse(last_board, new_board, move)) bot_handler.storage.put('last_fen', new_board.fen()) def move_computer( - self, - message: Dict[str, str], - bot_handler: BotHandler, - last_fen: str, - move_san: str + self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str, move_san: str ) -> None: """Preforms a move for a user in a game with the computer and then makes the computer's move. Replies to the bot handler. Unlike `move`, @@ -350,13 +276,7 @@ class ChessHandler: if not last_board: return - move = self.validate_move( - message, - bot_handler, - last_board, - move_san, - True - ) + move = self.validate_move(message, bot_handler, last_board, move_san, True) if not move: return @@ -367,40 +287,22 @@ class ChessHandler: if self.check_game_over(message, bot_handler, new_board): return - computer_move = calculate_computer_move( - new_board, - self.engine - ) + computer_move = calculate_computer_move(new_board, self.engine) new_board_after_computer_move = copy.copy(new_board) new_board_after_computer_move.push(computer_move) - if self.check_game_over( - message, - bot_handler, - new_board_after_computer_move - ): + if self.check_game_over(message, bot_handler, new_board_after_computer_move): return bot_handler.send_reply( - message, - make_move_reponse( - new_board, - new_board_after_computer_move, - computer_move - ) + message, make_move_reponse(new_board, new_board_after_computer_move, computer_move) ) - bot_handler.storage.put( - 'last_fen', - new_board_after_computer_move.fen() - ) + bot_handler.storage.put('last_fen', new_board_after_computer_move.fen()) def move_computer_first( - self, - message: Dict[str, str], - bot_handler: BotHandler, - last_fen: str + self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str ) -> None: """Preforms a move for the computer without having the user go first in a game with the computer. Replies to the bot handler. Like @@ -415,44 +317,24 @@ class ChessHandler: """ last_board = self.validate_board(message, bot_handler, last_fen) - computer_move = calculate_computer_move( - last_board, - self.engine - ) + computer_move = calculate_computer_move(last_board, self.engine) new_board_after_computer_move = copy.copy(last_board) # type: chess.Board new_board_after_computer_move.push(computer_move) - if self.check_game_over( - message, - bot_handler, - new_board_after_computer_move - ): + if self.check_game_over(message, bot_handler, new_board_after_computer_move): return bot_handler.send_reply( - message, - make_move_reponse( - last_board, - new_board_after_computer_move, - computer_move - ) + message, make_move_reponse(last_board, new_board_after_computer_move, computer_move) ) - bot_handler.storage.put( - 'last_fen', - new_board_after_computer_move.fen() - ) + bot_handler.storage.put('last_fen', new_board_after_computer_move.fen()) # `bot_handler`'s `storage` only accepts `str` values. bot_handler.storage.put('is_with_computer', str(True)) - def resign( - self, - message: Dict[str, str], - bot_handler: BotHandler, - last_fen: str - ) -> None: + def resign(self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str) -> None: """Resigns the game for the current player. Parameters: @@ -465,13 +347,12 @@ class ChessHandler: if not last_board: return - bot_handler.send_reply( - message, - make_loss_response(last_board, 'resigned') - ) + bot_handler.send_reply(message, make_loss_response(last_board, 'resigned')) + handler_class = ChessHandler + def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move: """Calculates the computer's move. @@ -485,6 +366,7 @@ def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move: best_move_and_ponder_move = engine.go(movetime=(3000)) return best_move_and_ponder_move[0] + def make_draw_response(reason: str) -> str: """Makes a response string for a draw. @@ -496,6 +378,7 @@ def make_draw_response(reason: str) -> str: """ return 'It\'s a draw because of {}!'.format(reason) + def make_loss_response(board: chess.Board, reason: str) -> str: """Makes a response string for a loss (or win). @@ -506,16 +389,14 @@ def make_loss_response(board: chess.Board, reason: str) -> str: Returns: The loss response string. """ - return ( - '*{}* {}. **{}** wins!\n\n' - '{}' - ).format( + return ('*{}* {}. **{}** wins!\n\n' '{}').format( 'White' if board.turn else 'Black', reason, 'Black' if board.turn else 'White', - make_str(board, board.turn) + make_str(board, board.turn), ) + def make_not_legal_response(board: chess.Board, move_san: str) -> str: """Makes a response string for a not-legal move. @@ -525,17 +406,11 @@ def make_not_legal_response(board: chess.Board, move_san: str) -> str: Returns: The not-legal-move response string. """ - return ( - 'Sorry, the move *{}* isn\'t legal.\n\n' - '{}' - '\n\n\n' - '{}' - ).format( - move_san, - make_str(board, board.turn), - make_footer() + return ('Sorry, the move *{}* isn\'t legal.\n\n' '{}' '\n\n\n' '{}').format( + move_san, make_str(board, board.turn), make_footer() ) + def make_copied_wrong_response() -> str: """Makes a response string for a FEN string that was copied wrong. @@ -546,6 +421,7 @@ def make_copied_wrong_response() -> str: 'Please try to copy the response again from the last message!' ) + def make_start_reponse(board: chess.Board) -> str: """Makes a response string for the first response of a game with another user. @@ -563,11 +439,8 @@ def make_start_reponse(board: chess.Board) -> str: 'Now it\'s **{}**\'s turn.' '\n\n\n' '{}' - ).format( - make_str(board, True), - 'white' if board.turn else 'black', - make_footer() - ) + ).format(make_str(board, True), 'white' if board.turn else 'black', make_footer()) + def make_start_computer_reponse(board: chess.Board) -> str: """Makes a response string for the first response of a game with a @@ -587,17 +460,10 @@ def make_start_computer_reponse(board: chess.Board) -> str: 'Now it\'s **{}**\'s turn.' '\n\n\n' '{}' - ).format( - make_str(board, True), - 'white' if board.turn else 'black', - make_footer() - ) + ).format(make_str(board, True), 'white' if board.turn else 'black', make_footer()) -def make_move_reponse( - last_board: chess.Board, - new_board: chess.Board, - move: chess.Move -) -> str: + +def make_move_reponse(last_board: chess.Board, new_board: chess.Board, move: chess.Move) -> str: """Makes a response string for after a move is made. Parameters: @@ -623,9 +489,10 @@ def make_move_reponse( last_board.san(move), make_str(new_board, new_board.turn), 'white' if new_board.turn else 'black', - make_footer() + make_footer(), ) + def make_footer() -> str: """Makes a footer to be appended to the bottom of other, actionable responses. @@ -637,6 +504,7 @@ def make_footer() -> str: 'response.*' ) + def make_str(board: chess.Board, is_white_on_bottom: bool) -> str: """Converts a board object into a string to be used in Markdown. Backticks are added around the string to preserve formatting. @@ -654,14 +522,14 @@ def make_str(board: chess.Board, is_white_on_bottom: bool) -> str: replaced_str = replace_with_unicode(default_str) replaced_and_guided_str = guide_with_numbers(replaced_str) properly_flipped_str = ( - replaced_and_guided_str if is_white_on_bottom - else replaced_and_guided_str[::-1] + replaced_and_guided_str if is_white_on_bottom else replaced_and_guided_str[::-1] ) trimmed_str = trim_whitespace_before_newline(properly_flipped_str) monospaced_str = '```\n{}\n```'.format(trimmed_str) return monospaced_str + def guide_with_numbers(board_str: str) -> str: """Adds numbers and letters on the side of a string without them made out of a board. @@ -702,12 +570,11 @@ def guide_with_numbers(board_str: str) -> str: row_str = (' '.join(row_list) + ' 1').replace('\n ', '\n') # a, b, c, d, e, f, g, and h are easy to add in. - row_and_col_str = ( - ' a b c d e f g h \n' + row_str + '\n a b c d e f g h ' - ) + row_and_col_str = ' a b c d e f g h \n' + row_str + '\n a b c d e f g h ' return row_and_col_str + def replace_with_unicode(board_str: str) -> str: """Replaces the default characters in a board object's string output with Unicode chess characters, e.g., '♖' instead of 'R.' @@ -737,6 +604,7 @@ def replace_with_unicode(board_str: str) -> str: return replaced_str + def trim_whitespace_before_newline(str_to_trim: str) -> str: """Removes any spaces before a newline in a string. diff --git a/zulip_bots/zulip_bots/bots/chessbot/test_chessbot.py b/zulip_bots/zulip_bots/bots/chessbot/test_chessbot.py index 0fba979..5d00256 100644 --- a/zulip_bots/zulip_bots/bots/chessbot/test_chessbot.py +++ b/zulip_bots/zulip_bots/bots/chessbot/test_chessbot.py @@ -114,9 +114,11 @@ To make your next move, respond to Chess Bot with def test_main(self) -> None: with self.mock_config_info({'stockfish_location': '/foo/bar'}): - self.verify_dialog([ - ('start with other user', self.START_RESPONSE), - ('do e4', self.DO_E4_RESPONSE), - ('do Ke4', self.DO_KE4_RESPONSE), - ('resign', self.RESIGN_RESPONSE), - ]) + self.verify_dialog( + [ + ('start with other user', self.START_RESPONSE), + ('do e4', self.DO_E4_RESPONSE), + ('do Ke4', self.DO_KE4_RESPONSE), + ('resign', self.RESIGN_RESPONSE), + ] + ) diff --git a/zulip_bots/zulip_bots/bots/connect_four/connect_four.py b/zulip_bots/zulip_bots/bots/connect_four/connect_four.py index 46847f9..fe5d25c 100644 --- a/zulip_bots/zulip_bots/bots/connect_four/connect_four.py +++ b/zulip_bots/zulip_bots/bots/connect_four/connect_four.py @@ -46,8 +46,10 @@ class ConnectFourBotHandler(GameAdapter): def __init__(self) -> None: game_name = 'Connect Four' bot_name = 'connect_four' - move_help_message = '* To make your move during a game, type\n' \ - '```move ``` or ``````' + move_help_message = ( + '* To make your move during a game, type\n' + '```move ``` or ``````' + ) move_regex = '(move ([1-7])$)|(([1-7])$)' model = ConnectFourModel gameMessageHandler = ConnectFourMessageHandler @@ -61,7 +63,7 @@ class ConnectFourBotHandler(GameAdapter): model, gameMessageHandler, rules, - max_players=2 + max_players=2, ) diff --git a/zulip_bots/zulip_bots/bots/connect_four/controller.py b/zulip_bots/zulip_bots/bots/connect_four/controller.py index bad7474..20bc5a0 100644 --- a/zulip_bots/zulip_bots/bots/connect_four/controller.py +++ b/zulip_bots/zulip_bots/bots/connect_four/controller.py @@ -17,7 +17,7 @@ class ConnectFourModel: [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0] + [0, 0, 0, 0, 0, 0, 0], ] self.current_board = self.blank_board @@ -27,10 +27,7 @@ class ConnectFourModel: def get_column(self, col): # We use this in tests. - return [ - self.current_board[i][col] - for i in range(6) - ] + return [self.current_board[i][col] for i in range(6)] def validate_move(self, column_number): if column_number < 0 or column_number > 6: @@ -76,8 +73,12 @@ class ConnectFourModel: for row in range(0, 6): for column in range(0, 4): - horizontal_sum = board[row][column] + board[row][column + 1] + \ - board[row][column + 2] + board[row][column + 3] + horizontal_sum = ( + board[row][column] + + board[row][column + 1] + + board[row][column + 2] + + board[row][column + 3] + ) if horizontal_sum == -4: return -1 elif horizontal_sum == 4: @@ -90,8 +91,12 @@ class ConnectFourModel: for row in range(0, 3): for column in range(0, 7): - vertical_sum = board[row][column] + board[row + 1][column] + \ - board[row + 2][column] + board[row + 3][column] + vertical_sum = ( + board[row][column] + + board[row + 1][column] + + board[row + 2][column] + + board[row + 3][column] + ) if vertical_sum == -4: return -1 elif vertical_sum == 4: @@ -106,8 +111,12 @@ class ConnectFourModel: # Major Diagonl Sum for row in range(0, 3): for column in range(0, 4): - major_diagonal_sum = board[row][column] + board[row + 1][column + 1] + \ - board[row + 2][column + 2] + board[row + 3][column + 3] + major_diagonal_sum = ( + board[row][column] + + board[row + 1][column + 1] + + board[row + 2][column + 2] + + board[row + 3][column + 3] + ) if major_diagonal_sum == -4: return -1 elif major_diagonal_sum == 4: @@ -116,8 +125,12 @@ class ConnectFourModel: # Minor Diagonal Sum for row in range(3, 6): for column in range(0, 4): - minor_diagonal_sum = board[row][column] + board[row - 1][column + 1] + \ - board[row - 2][column + 2] + board[row - 3][column + 3] + minor_diagonal_sum = ( + board[row][column] + + board[row - 1][column + 1] + + board[row - 2][column + 2] + + board[row - 3][column + 3] + ) if minor_diagonal_sum == -4: return -1 elif minor_diagonal_sum == 4: @@ -132,9 +145,11 @@ class ConnectFourModel: if top_row_multiple != 0: return 'draw' - winner = get_horizontal_wins(self.current_board) + \ - get_vertical_wins(self.current_board) + \ - get_diagonal_wins(self.current_board) + winner = ( + get_horizontal_wins(self.current_board) + + get_vertical_wins(self.current_board) + + get_diagonal_wins(self.current_board) + ) if winner == 1: return first_player diff --git a/zulip_bots/zulip_bots/bots/connect_four/test_connect_four.py b/zulip_bots/zulip_bots/bots/connect_four/test_connect_four.py index 6f8765c..cb0cffa 100644 --- a/zulip_bots/zulip_bots/bots/connect_four/test_connect_four.py +++ b/zulip_bots/zulip_bots/bots/connect_four/test_connect_four.py @@ -9,20 +9,19 @@ class TestConnectFourBot(BotTestCase, DefaultTests): bot_name = 'connect_four' def make_request_message( - self, - content: str, - user: str = 'foo@example.com', - user_name: str = 'foo' + self, content: str, user: str = 'foo@example.com', user_name: str = 'foo' ) -> Dict[str, str]: - message = dict( - sender_email=user, - content=content, - sender_full_name=user_name - ) + message = dict(sender_email=user, content=content, sender_full_name=user_name) return message # Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled - def verify_response(self, request: str, expected_response: str, response_number: int, user: str = 'foo@example.com') -> None: + def verify_response( + self, + request: str, + expected_response: str, + response_number: int, + user: str = 'foo@example.com', + ) -> None: ''' This function serves a similar purpose to BotTestCase.verify_dialog, but allows @@ -36,11 +35,7 @@ class TestConnectFourBot(BotTestCase, DefaultTests): bot.handle_message(message, bot_handler) - responses = [ - message - for (method, message) - in bot_handler.transcript - ] + responses = [message for (method, message) in bot_handler.transcript] first_response = responses[response_number] self.assertEqual(expected_response, first_response['content']) @@ -73,7 +68,9 @@ class TestConnectFourBot(BotTestCase, DefaultTests): self.verify_response('help', self.help_message(), 0) def test_game_message_handler_responses(self) -> None: - board = ':one: :two: :three: :four: :five: :six: :seven:\n\n' + '\ + board = ( + ':one: :two: :three: :four: :five: :six: :seven:\n\n' + + '\ :white_circle: :white_circle: :white_circle: :white_circle: \ :white_circle: :white_circle: :white_circle: \n\n\ :white_circle: :white_circle: :white_circle: :white_circle: \ @@ -86,16 +83,18 @@ class TestConnectFourBot(BotTestCase, DefaultTests): :white_circle: :white_circle: \n\n\ :blue_circle: :red_circle: :white_circle: :white_circle: :white_circle: \ :white_circle: :white_circle: ' + ) bot, bot_handler = self._get_handlers() - self.assertEqual(bot.gameMessageHandler.parse_board( - self.almost_win_board), board) + self.assertEqual(bot.gameMessageHandler.parse_board(self.almost_win_board), board) + self.assertEqual(bot.gameMessageHandler.get_player_color(1), ':red_circle:') self.assertEqual( - bot.gameMessageHandler.get_player_color(1), ':red_circle:') - self.assertEqual(bot.gameMessageHandler.alert_move_message( - 'foo', 'move 6'), 'foo moved in column 6') - self.assertEqual(bot.gameMessageHandler.game_start_message( - ), 'Type `move ` or `` to place a token.\n\ -The first player to get 4 in a row wins!\n Good Luck!') + bot.gameMessageHandler.alert_move_message('foo', 'move 6'), 'foo moved in column 6' + ) + self.assertEqual( + bot.gameMessageHandler.game_start_message(), + 'Type `move ` or `` to place a token.\n\ +The first player to get 4 in a row wins!\n Good Luck!', + ) blank_board = [ [0, 0, 0, 0, 0, 0, 0], @@ -103,7 +102,8 @@ The first player to get 4 in a row wins!\n Good Luck!') [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]] + [0, 0, 0, 0, 0, 0, 0], + ] almost_win_board = [ [0, 0, 0, 0, 0, 0, 0], @@ -111,7 +111,8 @@ The first player to get 4 in a row wins!\n Good Luck!') [0, 0, 0, 0, 0, 0, 0], [1, -1, 0, 0, 0, 0, 0], [1, -1, 0, 0, 0, 0, 0], - [1, -1, 0, 0, 0, 0, 0]] + [1, -1, 0, 0, 0, 0, 0], + ] almost_draw_board = [ [1, -1, 1, -1, 1, -1, 0], @@ -119,13 +120,12 @@ The first player to get 4 in a row wins!\n Good Luck!') [0, 0, 0, 0, 0, 0, -1], [0, 0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0, -1], - [0, 0, 0, 0, 0, 0, 1]] + [0, 0, 0, 0, 0, 0, 1], + ] def test_connect_four_logic(self) -> None: def confirmAvailableMoves( - good_moves: List[int], - bad_moves: List[int], - board: List[List[int]] + good_moves: List[int], bad_moves: List[int], board: List[List[int]] ) -> None: connectFourModel.update_board(board) @@ -139,18 +139,16 @@ The first player to get 4 in a row wins!\n Good Luck!') column_number: int, token_number: int, initial_board: List[List[int]], - final_board: List[List[int]] + final_board: List[List[int]], ) -> None: connectFourModel.update_board(initial_board) - test_board = connectFourModel.make_move( - 'move ' + str(column_number), token_number) + test_board = connectFourModel.make_move('move ' + str(column_number), token_number) self.assertEqual(test_board, final_board) def confirmGameOver(board: List[List[int]], result: str) -> None: connectFourModel.update_board(board) - game_over = connectFourModel.determine_game_over( - ['first_player', 'second_player']) + game_over = connectFourModel.determine_game_over(['first_player', 'second_player']) self.assertEqual(game_over, result) @@ -170,7 +168,8 @@ The first player to get 4 in a row wins!\n Good Luck!') [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]] + [0, 0, 0, 0, 0, 0, 0], + ] full_board = [ [1, 1, 1, 1, 1, 1, 1], @@ -178,7 +177,8 @@ The first player to get 4 in a row wins!\n Good Luck!') [1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1]] + [1, 1, 1, 1, 1, 1, 1], + ] single_column_board = [ [1, 1, 1, 0, 1, 1, 1], @@ -186,7 +186,8 @@ The first player to get 4 in a row wins!\n Good Luck!') [1, 1, 1, 0, 1, 1, 1], [1, 1, 1, 0, 1, 1, 1], [1, 1, 1, 0, 1, 1, 1], - [1, 1, 1, 0, 1, 1, 1]] + [1, 1, 1, 0, 1, 1, 1], + ] diagonal_board = [ [0, 0, 0, 0, 0, 0, 1], @@ -194,7 +195,8 @@ The first player to get 4 in a row wins!\n Good Luck!') [0, 0, 0, 0, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1], [0, 0, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1]] + [0, 1, 1, 1, 1, 1, 1], + ] # Winning Board Setups # Each array if consists of two arrays: @@ -204,190 +206,222 @@ The first player to get 4 in a row wins!\n Good Luck!') # for simplicity (random -1 and 1s could be added) horizontal_win_boards = [ [ - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [1, 1, 1, 1, 0, 0, 0]], - - [[0, 0, 0, 1, 1, 1, 1], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 1, 1, 1, 1, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]] + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 1, 1, 1, 0, 0, 0], + ], + [ + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], ], [ - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [-1, -1, -1, -1, 0, 0, 0]], - - [[0, 0, 0, -1, -1, -1, -1], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, -1, -1, -1, -1, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]] - ] + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [-1, -1, -1, -1, 0, 0, 0], + ], + [ + [0, 0, 0, -1, -1, -1, -1], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, -1, -1, -1, -1, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + ], ] vertical_win_boards = [ [ - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]] + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], ], [ - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [-1, 0, 0, 0, 0, 0, 0], - [-1, 0, 0, 0, 0, 0, 0], - [-1, 0, 0, 0, 0, 0, 0], - [-1, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, -1], - [0, 0, 0, 0, 0, 0, -1], - [0, 0, 0, 0, 0, 0, -1], - [0, 0, 0, 0, 0, 0, -1], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]] - ] + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + ], ] major_diagonal_win_boards = [ [ - [[1, 0, 0, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0, 0], - [0, 0, 1, 0, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 0, 1, 0, 0], - [0, 0, 0, 0, 0, 1, 0], - [0, 0, 0, 0, 0, 0, 1]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 1, 0, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 0, 1, 0, 0], - [0, 0, 0, 0, 0, 1, 0], - [0, 0, 0, 0, 0, 0, 0]] + [ + [1, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 1], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0], + ], ], [ - [[-1, 0, 0, 0, 0, 0, 0], - [0, -1, 0, 0, 0, 0, 0], - [0, 0, -1, 0, 0, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, 0, 0, -1, 0, 0], - [0, 0, 0, 0, 0, -1, 0], - [0, 0, 0, 0, 0, 0, -1]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, -1, 0, 0, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, 0, 0, -1, 0, 0], - [0, 0, 0, 0, 0, -1, 0], - [0, 0, 0, 0, 0, 0, 0]] - ] + [ + [-1, 0, 0, 0, 0, 0, 0], + [0, -1, 0, 0, 0, 0, 0], + [0, 0, -1, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, -1, 0, 0], + [0, 0, 0, 0, 0, -1, 0], + [0, 0, 0, 0, 0, 0, -1], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, -1, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, -1, 0, 0], + [0, 0, 0, 0, 0, -1, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + ], ] minor_diagonal_win_boards = [ [ - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 1, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 1, 0], - [0, 0, 0, 0, 1, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 1, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 1, 0, 0, 0, 0], - [0, 1, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]] + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], ], [ - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, -1, 0, 0, 0, 0], - [0, -1, 0, 0, 0, 0, 0], - [-1, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, -1], - [0, 0, 0, 0, 0, -1, 0], - [0, 0, 0, 0, -1, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]], - - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, -1, 0, 0], - [0, 0, 0, -1, 0, 0, 0], - [0, 0, -1, 0, 0, 0, 0], - [0, -1, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]] - ] + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, -1, 0, 0, 0, 0], + [0, -1, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, -1, 0], + [0, 0, 0, 0, -1, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, -1, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, -1, 0, 0, 0, 0], + [0, -1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + ], ] # Test Move Validation Logic @@ -397,8 +431,7 @@ The first player to get 4 in a row wins!\n Good Luck!') # Test Available Move Logic connectFourModel.update_board(blank_board) - self.assertEqual(connectFourModel.available_moves(), - [0, 1, 2, 3, 4, 5, 6]) + self.assertEqual(connectFourModel.available_moves(), [0, 1, 2, 3, 4, 5, 6]) connectFourModel.update_board(single_column_board) self.assertEqual(connectFourModel.available_moves(), [3]) @@ -407,69 +440,117 @@ The first player to get 4 in a row wins!\n Good Luck!') self.assertEqual(connectFourModel.available_moves(), []) # Test Move Logic - confirmMove(1, 0, blank_board, - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0]]) + confirmMove( + 1, + 0, + blank_board, + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + ], + ) - confirmMove(1, 1, blank_board, - [[0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [-1, 0, 0, 0, 0, 0, 0]]) + confirmMove( + 1, + 1, + blank_board, + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + ], + ) - confirmMove(1, 0, diagonal_board, - [[0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 1, 1], - [0, 0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1, 1], - [0, 0, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1]]) + confirmMove( + 1, + 0, + diagonal_board, + [ + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + ], + ) - confirmMove(2, 0, diagonal_board, - [[0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 1, 1], - [0, 0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1]]) + confirmMove( + 2, + 0, + diagonal_board, + [ + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1], + ], + ) - confirmMove(3, 0, diagonal_board, - [[0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 1, 1], - [0, 0, 0, 0, 1, 1, 1], - [0, 0, 1, 1, 1, 1, 1], - [0, 0, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1]]) + confirmMove( + 3, + 0, + diagonal_board, + [ + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1], + ], + ) - confirmMove(4, 0, diagonal_board, - [[0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 0, 1, 1], - [0, 0, 0, 1, 1, 1, 1], - [0, 0, 0, 1, 1, 1, 1], - [0, 0, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1]]) + confirmMove( + 4, + 0, + diagonal_board, + [ + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1], + ], + ) - confirmMove(5, 0, diagonal_board, - [[0, 0, 0, 0, 0, 0, 1], - [0, 0, 0, 0, 1, 1, 1], - [0, 0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1, 1], - [0, 0, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1]]) + confirmMove( + 5, + 0, + diagonal_board, + [ + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1], + ], + ) - confirmMove(6, 0, diagonal_board, - [[0, 0, 0, 0, 0, 1, 1], - [0, 0, 0, 0, 0, 1, 1], - [0, 0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1, 1], - [0, 0, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1]]) + confirmMove( + 6, + 0, + diagonal_board, + [ + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1], + ], + ) # Test Game Over Logic: confirmGameOver(blank_board, '') diff --git a/zulip_bots/zulip_bots/bots/converter/converter.py b/zulip_bots/zulip_bots/bots/converter/converter.py index 979da70..272b889 100644 --- a/zulip_bots/zulip_bots/bots/converter/converter.py +++ b/zulip_bots/zulip_bots/bots/converter/converter.py @@ -15,14 +15,17 @@ def is_float(value: Any) -> bool: except ValueError: return False + # Rounds the number 'x' to 'digits' significant digits. # A normal 'round()' would round the number to an absolute amount of # fractional decimals, e.g. 0.00045 would become 0.0. # 'round_to()' rounds only the digits that are not 0. # 0.00045 would then become 0.0005. + def round_to(x: float, digits: int) -> float: - return round(x, digits-int(floor(log10(abs(x))))) + return round(x, digits - int(floor(log10(abs(x))))) + class ConverterHandler: ''' @@ -49,6 +52,7 @@ class ConverterHandler: bot_response = get_bot_converter_response(message, bot_handler) bot_handler.send_reply(message, bot_response) + def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) -> str: content = message['content'] @@ -78,10 +82,10 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) for key, exp in utils.PREFIXES.items(): if unit_from.startswith(key): exponent += exp - unit_from = unit_from[len(key):] + unit_from = unit_from[len(key) :] if unit_to.startswith(key): exponent -= exp - unit_to = unit_to[len(key):] + unit_to = unit_to[len(key) :] uf_to_std = utils.UNITS.get(unit_from, []) # type: List[Any] ut_to_std = utils.UNITS.get(unit_to, []) # type: List[Any] @@ -97,8 +101,13 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) if uf_to_std[2] != ut_to_std[2]: unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from results.append( - '`' + unit_to.capitalize() + '` and `' + unit_from + '`' - + ' are not from the same category. ' + utils.QUICK_HELP + '`' + + unit_to.capitalize() + + '` and `' + + unit_from + + '`' + + ' are not from the same category. ' + + utils.QUICK_HELP ) continue @@ -114,10 +123,11 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) number_res *= 10 ** exponent number_res = round_to(number_res, 7) - results.append('{} {} = {} {}'.format(number, - words[convert_index + 2], - number_res, - words[convert_index + 3])) + results.append( + '{} {} = {} {}'.format( + number, words[convert_index + 2], number_res, words[convert_index + 3] + ) + ) else: results.append('Too few arguments given. ' + utils.QUICK_HELP) @@ -128,4 +138,5 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) return new_content + handler_class = ConverterHandler diff --git a/zulip_bots/zulip_bots/bots/converter/test_converter.py b/zulip_bots/zulip_bots/bots/converter/test_converter.py index 6aada3d..6409eb6 100755 --- a/zulip_bots/zulip_bots/bots/converter/test_converter.py +++ b/zulip_bots/zulip_bots/bots/converter/test_converter.py @@ -7,25 +7,41 @@ class TestConverterBot(BotTestCase, DefaultTests): def test_bot(self) -> None: dialog = [ - ("", 'Too few arguments given. Enter `@convert help` ' - 'for help on using the converter.\n'), - ("foo bar", 'Too few arguments given. Enter `@convert help` ' - 'for help on using the converter.\n'), + ( + "", + 'Too few arguments given. Enter `@convert help` ' + 'for help on using the converter.\n', + ), + ( + "foo bar", + 'Too few arguments given. Enter `@convert help` ' + 'for help on using the converter.\n', + ), ("2 m cm", "2 m = 200.0 cm\n"), ("12.0 celsius fahrenheit", "12.0 celsius = 53.600054 fahrenheit\n"), ("0.002 kilometer millimile", "0.002 kilometer = 1.2427424 millimile\n"), ("3 megabyte kilobit", "3 megabyte = 24576.0 kilobit\n"), ("foo m cm", "`foo` is not a valid number. " + utils.QUICK_HELP + "\n"), - ("@convert help", "1. conversion: Too few arguments given. " - "Enter `@convert help` for help on using the converter.\n" - "2. conversion: " + utils.HELP_MESSAGE + "\n"), - ("2 celsius kilometer", "`Meter` and `Celsius` are not from the same category. " - "Enter `@convert help` for help on using the converter.\n"), - ("2 foo kilometer", "`foo` is not a valid unit." - " Enter `@convert help` for help on using the converter.\n"), - ("2 kilometer foo", "`foo` is not a valid unit." - "Enter `@convert help` for help on using the converter.\n"), - - + ( + "@convert help", + "1. conversion: Too few arguments given. " + "Enter `@convert help` for help on using the converter.\n" + "2. conversion: " + utils.HELP_MESSAGE + "\n", + ), + ( + "2 celsius kilometer", + "`Meter` and `Celsius` are not from the same category. " + "Enter `@convert help` for help on using the converter.\n", + ), + ( + "2 foo kilometer", + "`foo` is not a valid unit." + " Enter `@convert help` for help on using the converter.\n", + ), + ( + "2 kilometer foo", + "`foo` is not a valid unit." + "Enter `@convert help` for help on using the converter.\n", + ), ] self.verify_dialog(dialog) diff --git a/zulip_bots/zulip_bots/bots/converter/utils.py b/zulip_bots/zulip_bots/bots/converter/utils.py index aa2a2a9..0e87b58 100644 --- a/zulip_bots/zulip_bots/bots/converter/utils.py +++ b/zulip_bots/zulip_bots/bots/converter/utils.py @@ -2,145 +2,153 @@ # An entry consists of the unit's name, a constant number and a constant # factor that need to be added and multiplied to convert the unit into # the base unit in the last parameter. -UNITS = {'bit': [0, 1, 'bit'], - 'byte': [0, 8, 'bit'], - 'cubic-centimeter': [0, 0.000001, 'cubic-meter'], - 'cubic-decimeter': [0, 0.001, 'cubic-meter'], - 'liter': [0, 0.001, 'cubic-meter'], - 'cubic-meter': [0, 1, 'cubic-meter'], - 'cubic-inch': [0, 0.000016387064, 'cubic-meter'], - 'fluid-ounce': [0, 0.000029574, 'cubic-meter'], - 'cubic-foot': [0, 0.028316846592, 'cubic-meter'], - 'cubic-yard': [0, 0.764554857984, 'cubic-meter'], - 'teaspoon': [0, 0.0000049289216, 'cubic-meter'], - 'tablespoon': [0, 0.000014787, 'cubic-meter'], - 'cup': [0, 0.00023658823648491, 'cubic-meter'], - 'gram': [0, 1, 'gram'], - 'kilogram': [0, 1000, 'gram'], - 'ton': [0, 1000000, 'gram'], - 'ounce': [0, 28.349523125, 'gram'], - 'pound': [0, 453.59237, 'gram'], - 'kelvin': [0, 1, 'kelvin'], - 'celsius': [273.15, 1, 'kelvin'], - 'fahrenheit': [255.372222, 0.555555, 'kelvin'], - 'centimeter': [0, 0.01, 'meter'], - 'decimeter': [0, 0.1, 'meter'], - 'meter': [0, 1, 'meter'], - 'kilometer': [0, 1000, 'meter'], - 'inch': [0, 0.0254, 'meter'], - 'foot': [0, 0.3048, 'meter'], - 'yard': [0, 0.9144, 'meter'], - 'mile': [0, 1609.344, 'meter'], - 'nautical-mile': [0, 1852, 'meter'], - 'square-centimeter': [0, 0.0001, 'square-meter'], - 'square-decimeter': [0, 0.01, 'square-meter'], - 'square-meter': [0, 1, 'square-meter'], - 'square-kilometer': [0, 1000000, 'square-meter'], - 'square-inch': [0, 0.00064516, 'square-meter'], - 'square-foot': [0, 0.09290304, 'square-meter'], - 'square-yard': [0, 0.83612736, 'square-meter'], - 'square-mile': [0, 2589988.110336, 'square-meter'], - 'are': [0, 100, 'square-meter'], - 'hectare': [0, 10000, 'square-meter'], - 'acre': [0, 4046.8564224, 'square-meter']} +UNITS = { + 'bit': [0, 1, 'bit'], + 'byte': [0, 8, 'bit'], + 'cubic-centimeter': [0, 0.000001, 'cubic-meter'], + 'cubic-decimeter': [0, 0.001, 'cubic-meter'], + 'liter': [0, 0.001, 'cubic-meter'], + 'cubic-meter': [0, 1, 'cubic-meter'], + 'cubic-inch': [0, 0.000016387064, 'cubic-meter'], + 'fluid-ounce': [0, 0.000029574, 'cubic-meter'], + 'cubic-foot': [0, 0.028316846592, 'cubic-meter'], + 'cubic-yard': [0, 0.764554857984, 'cubic-meter'], + 'teaspoon': [0, 0.0000049289216, 'cubic-meter'], + 'tablespoon': [0, 0.000014787, 'cubic-meter'], + 'cup': [0, 0.00023658823648491, 'cubic-meter'], + 'gram': [0, 1, 'gram'], + 'kilogram': [0, 1000, 'gram'], + 'ton': [0, 1000000, 'gram'], + 'ounce': [0, 28.349523125, 'gram'], + 'pound': [0, 453.59237, 'gram'], + 'kelvin': [0, 1, 'kelvin'], + 'celsius': [273.15, 1, 'kelvin'], + 'fahrenheit': [255.372222, 0.555555, 'kelvin'], + 'centimeter': [0, 0.01, 'meter'], + 'decimeter': [0, 0.1, 'meter'], + 'meter': [0, 1, 'meter'], + 'kilometer': [0, 1000, 'meter'], + 'inch': [0, 0.0254, 'meter'], + 'foot': [0, 0.3048, 'meter'], + 'yard': [0, 0.9144, 'meter'], + 'mile': [0, 1609.344, 'meter'], + 'nautical-mile': [0, 1852, 'meter'], + 'square-centimeter': [0, 0.0001, 'square-meter'], + 'square-decimeter': [0, 0.01, 'square-meter'], + 'square-meter': [0, 1, 'square-meter'], + 'square-kilometer': [0, 1000000, 'square-meter'], + 'square-inch': [0, 0.00064516, 'square-meter'], + 'square-foot': [0, 0.09290304, 'square-meter'], + 'square-yard': [0, 0.83612736, 'square-meter'], + 'square-mile': [0, 2589988.110336, 'square-meter'], + 'are': [0, 100, 'square-meter'], + 'hectare': [0, 10000, 'square-meter'], + 'acre': [0, 4046.8564224, 'square-meter'], +} -PREFIXES = {'atto': -18, - 'femto': -15, - 'pico': -12, - 'nano': -9, - 'micro': -6, - 'milli': -3, - 'centi': -2, - 'deci': -1, - 'deca': 1, - 'hecto': 2, - 'kilo': 3, - 'mega': 6, - 'giga': 9, - 'tera': 12, - 'peta': 15, - 'exa': 18} +PREFIXES = { + 'atto': -18, + 'femto': -15, + 'pico': -12, + 'nano': -9, + 'micro': -6, + 'milli': -3, + 'centi': -2, + 'deci': -1, + 'deca': 1, + 'hecto': 2, + 'kilo': 3, + 'mega': 6, + 'giga': 9, + 'tera': 12, + 'peta': 15, + 'exa': 18, +} -ALIASES = {'a': 'are', - 'ac': 'acre', - 'c': 'celsius', - 'cm': 'centimeter', - 'cm2': 'square-centimeter', - 'cm3': 'cubic-centimeter', - 'cm^2': 'square-centimeter', - 'cm^3': 'cubic-centimeter', - 'dm': 'decimeter', - 'dm2': 'square-decimeter', - 'dm3': 'cubic-decimeter', - 'dm^2': 'square-decimeter', - 'dm^3': 'cubic-decimeter', - 'f': 'fahrenheit', - 'fl-oz': 'fluid-ounce', - 'ft': 'foot', - 'ft2': 'square-foot', - 'ft3': 'cubic-foot', - 'ft^2': 'square-foot', - 'ft^3': 'cubic-foot', - 'g': 'gram', - 'ha': 'hectare', - 'in': 'inch', - 'in2': 'square-inch', - 'in3': 'cubic-inch', - 'in^2': 'square-inch', - 'in^3': 'cubic-inch', - 'k': 'kelvin', - 'kg': 'kilogram', - 'km': 'kilometer', - 'km2': 'square-kilometer', - 'km^2': 'square-kilometer', - 'l': 'liter', - 'lb': 'pound', - 'm': 'meter', - 'm2': 'square-meter', - 'm3': 'cubic-meter', - 'm^2': 'square-meter', - 'm^3': 'cubic-meter', - 'mi': 'mile', - 'mi2': 'square-mile', - 'mi^2': 'square-mile', - 'nmi': 'nautical-mile', - 'oz': 'ounce', - 't': 'ton', - 'tbsp': 'tablespoon', - 'tsp': 'teaspoon', - 'y': 'yard', - 'y2': 'square-yard', - 'y3': 'cubic-yard', - 'y^2': 'square-yard', - 'y^3': 'cubic-yard'} +ALIASES = { + 'a': 'are', + 'ac': 'acre', + 'c': 'celsius', + 'cm': 'centimeter', + 'cm2': 'square-centimeter', + 'cm3': 'cubic-centimeter', + 'cm^2': 'square-centimeter', + 'cm^3': 'cubic-centimeter', + 'dm': 'decimeter', + 'dm2': 'square-decimeter', + 'dm3': 'cubic-decimeter', + 'dm^2': 'square-decimeter', + 'dm^3': 'cubic-decimeter', + 'f': 'fahrenheit', + 'fl-oz': 'fluid-ounce', + 'ft': 'foot', + 'ft2': 'square-foot', + 'ft3': 'cubic-foot', + 'ft^2': 'square-foot', + 'ft^3': 'cubic-foot', + 'g': 'gram', + 'ha': 'hectare', + 'in': 'inch', + 'in2': 'square-inch', + 'in3': 'cubic-inch', + 'in^2': 'square-inch', + 'in^3': 'cubic-inch', + 'k': 'kelvin', + 'kg': 'kilogram', + 'km': 'kilometer', + 'km2': 'square-kilometer', + 'km^2': 'square-kilometer', + 'l': 'liter', + 'lb': 'pound', + 'm': 'meter', + 'm2': 'square-meter', + 'm3': 'cubic-meter', + 'm^2': 'square-meter', + 'm^3': 'cubic-meter', + 'mi': 'mile', + 'mi2': 'square-mile', + 'mi^2': 'square-mile', + 'nmi': 'nautical-mile', + 'oz': 'ounce', + 't': 'ton', + 'tbsp': 'tablespoon', + 'tsp': 'teaspoon', + 'y': 'yard', + 'y2': 'square-yard', + 'y3': 'cubic-yard', + 'y^2': 'square-yard', + 'y^3': 'cubic-yard', +} -HELP_MESSAGE = ('Converter usage:\n' - '`@convert `\n' - 'Converts `number` in the unit to ' - 'the and prints the result\n' - '`number`: integer or floating point number, e.g. 12, 13.05, 0.002\n' - ' and are two of the following units:\n' - '* square-centimeter (cm^2, cm2), square-decimeter (dm^2, dm2), ' - 'square-meter (m^2, m2), square-kilometer (km^2, km2),' - ' square-inch (in^2, in2), square-foot (ft^2, ft2), square-yard (y^2, y2), ' - ' square-mile(mi^2, mi2), are (a), hectare (ha), acre (ac)\n' - '* bit, byte\n' - '* centimeter (cm), decimeter(dm), meter (m),' - ' kilometer (km), inch (in), foot (ft), yard (y),' - ' mile (mi), nautical-mile (nmi)\n' - '* Kelvin (K), Celsius(C), Fahrenheit (F)\n' - '* cubic-centimeter (cm^3, cm3), cubic-decimeter (dm^3, dm3), liter (l), ' - 'cubic-meter (m^3, m3), cubic-inch (in^3, in3), fluid-ounce (fl-oz), ' - 'cubic-foot (ft^3, ft3), cubic-yard (y^3, y3)\n' - '* gram (g), kilogram (kg), ton (t), ounce (oz), pound(lb)\n' - '* (metric only, U.S. and imperial units differ slightly:) teaspoon (tsp), tablespoon (tbsp), cup\n\n\n' - 'Allowed prefixes are:\n' - '* atto, pico, femto, nano, micro, milli, centi, deci\n' - '* deca, hecto, kilo, mega, giga, tera, peta, exa\n\n\n' - 'Usage examples:\n' - '* `@convert 12 celsius fahrenheit`\n' - '* `@convert 0.002 kilomile millimeter`\n' - '* `@convert 31.5 square-mile ha`\n' - '* `@convert 56 g lb`\n') +HELP_MESSAGE = ( + 'Converter usage:\n' + '`@convert `\n' + 'Converts `number` in the unit to ' + 'the and prints the result\n' + '`number`: integer or floating point number, e.g. 12, 13.05, 0.002\n' + ' and are two of the following units:\n' + '* square-centimeter (cm^2, cm2), square-decimeter (dm^2, dm2), ' + 'square-meter (m^2, m2), square-kilometer (km^2, km2),' + ' square-inch (in^2, in2), square-foot (ft^2, ft2), square-yard (y^2, y2), ' + ' square-mile(mi^2, mi2), are (a), hectare (ha), acre (ac)\n' + '* bit, byte\n' + '* centimeter (cm), decimeter(dm), meter (m),' + ' kilometer (km), inch (in), foot (ft), yard (y),' + ' mile (mi), nautical-mile (nmi)\n' + '* Kelvin (K), Celsius(C), Fahrenheit (F)\n' + '* cubic-centimeter (cm^3, cm3), cubic-decimeter (dm^3, dm3), liter (l), ' + 'cubic-meter (m^3, m3), cubic-inch (in^3, in3), fluid-ounce (fl-oz), ' + 'cubic-foot (ft^3, ft3), cubic-yard (y^3, y3)\n' + '* gram (g), kilogram (kg), ton (t), ounce (oz), pound(lb)\n' + '* (metric only, U.S. and imperial units differ slightly:) teaspoon (tsp), tablespoon (tbsp), cup\n\n\n' + 'Allowed prefixes are:\n' + '* atto, pico, femto, nano, micro, milli, centi, deci\n' + '* deca, hecto, kilo, mega, giga, tera, peta, exa\n\n\n' + 'Usage examples:\n' + '* `@convert 12 celsius fahrenheit`\n' + '* `@convert 0.002 kilomile millimeter`\n' + '* `@convert 31.5 square-mile ha`\n' + '* `@convert 56 g lb`\n' +) QUICK_HELP = 'Enter `@convert help` for help on using the converter.' diff --git a/zulip_bots/zulip_bots/bots/define/define.py b/zulip_bots/zulip_bots/bots/define/define.py index 0441eb2..34fe45d 100644 --- a/zulip_bots/zulip_bots/bots/define/define.py +++ b/zulip_bots/zulip_bots/bots/define/define.py @@ -66,7 +66,9 @@ class DefineHandler: # Show definitions line by line. for d in definitions: example = d['example'] if d['example'] else '*No example available.*' - response += '\n' + '* (**{}**) {}\n  {}'.format(d['type'], d['definition'], html2text.html2text(example)) + response += '\n' + '* (**{}**) {}\n  {}'.format( + d['type'], d['definition'], html2text.html2text(example) + ) except Exception: response += self.REQUEST_ERROR_MESSAGE @@ -74,4 +76,5 @@ class DefineHandler: return response + handler_class = DefineHandler diff --git a/zulip_bots/zulip_bots/bots/define/test_define.py b/zulip_bots/zulip_bots/bots/define/test_define.py index 73b5077..e46261c 100755 --- a/zulip_bots/zulip_bots/bots/define/test_define.py +++ b/zulip_bots/zulip_bots/bots/define/test_define.py @@ -9,25 +9,29 @@ class TestDefineBot(BotTestCase, DefaultTests): def test_bot(self) -> None: # Only one type(noun) of word. - bot_response = ("**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal " - "with soft fur, a short snout, and retractile claws. It is widely " - "kept as a pet or for catching mice, and many breeds have been " - "developed.\n  their pet cat\n\n") + bot_response = ( + "**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal " + "with soft fur, a short snout, and retractile claws. It is widely " + "kept as a pet or for catching mice, and many breeds have been " + "developed.\n  their pet cat\n\n" + ) with self.mock_http_conversation('test_single_type_word'): self.verify_reply('cat', bot_response) # Multi-type word. - bot_response = ("**help**:\n\n" - "* (**verb**) make it easier or possible for (someone) to do something by offering them one's services or resources.\n" - "  they helped her with domestic chores\n\n\n" - "* (**verb**) serve someone with (food or drink).\n" - "  may I help you to some more meat?\n\n\n" - "* (**verb**) cannot or could not avoid.\n" - "  he couldn't help laughing\n\n\n" - "* (**noun**) the action of helping someone to do something.\n" - "  I asked for help from my neighbours\n\n\n" - "* (**exclamation**) used as an appeal for urgent assistance.\n" - "  Help! I'm drowning!\n\n") + bot_response = ( + "**help**:\n\n" + "* (**verb**) make it easier or possible for (someone) to do something by offering them one's services or resources.\n" + "  they helped her with domestic chores\n\n\n" + "* (**verb**) serve someone with (food or drink).\n" + "  may I help you to some more meat?\n\n\n" + "* (**verb**) cannot or could not avoid.\n" + "  he couldn't help laughing\n\n\n" + "* (**noun**) the action of helping someone to do something.\n" + "  I asked for help from my neighbours\n\n\n" + "* (**exclamation**) used as an appeal for urgent assistance.\n" + "  Help! I'm drowning!\n\n" + ) with self.mock_http_conversation('test_multi_type_word'): self.verify_reply('help', bot_response) @@ -49,8 +53,5 @@ class TestDefineBot(BotTestCase, DefaultTests): self.verify_reply('', bot_response) def test_connection_error(self) -> None: - with patch('requests.get', side_effect=Exception), \ - patch('logging.exception'): - self.verify_reply( - 'aeroplane', - '**aeroplane**:\nCould not load definition.') + with patch('requests.get', side_effect=Exception), patch('logging.exception'): + self.verify_reply('aeroplane', '**aeroplane**:\nCould not load definition.') diff --git a/zulip_bots/zulip_bots/bots/dialogflow/dialogflow.py b/zulip_bots/zulip_bots/bots/dialogflow/dialogflow.py index 006e48c..031db0e 100644 --- a/zulip_bots/zulip_bots/bots/dialogflow/dialogflow.py +++ b/zulip_bots/zulip_bots/bots/dialogflow/dialogflow.py @@ -12,6 +12,7 @@ This bot will interact with dialogflow bots. Simply send this bot a message, and it will respond depending on the configured bot's behaviour. ''' + def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str) -> str: if message_content.strip() == '' or message_content.strip() == 'help': return config['bot_info'] @@ -24,7 +25,9 @@ def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str) res_str = response.read().decode('utf8', 'ignore') res_json = json.loads(res_str) if res_json['status']['errorType'] != 'success' and 'result' not in res_json.keys(): - return 'Error {}: {}.'.format(res_json['status']['code'], res_json['status']['errorDetails']) + return 'Error {}: {}.'.format( + res_json['status']['code'], res_json['status']['errorDetails'] + ) if res_json['result']['fulfillment']['speech'] == '': if 'alternateResult' in res_json.keys(): if res_json['alternateResult']['fulfillment']['speech'] != '': @@ -35,6 +38,7 @@ def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str) logging.exception(str(e)) return 'Error. {}.'.format(str(e)) + class DialogFlowHandler: ''' This plugin allows users to easily add their own @@ -54,4 +58,5 @@ class DialogFlowHandler: result = get_bot_result(message['content'], self.config_info, message['sender_id']) bot_handler.send_reply(message, result) + handler_class = DialogFlowHandler diff --git a/zulip_bots/zulip_bots/bots/dialogflow/test_dialogflow.py b/zulip_bots/zulip_bots/bots/dialogflow/test_dialogflow.py index a34568d..40ed971 100644 --- a/zulip_bots/zulip_bots/bots/dialogflow/test_dialogflow.py +++ b/zulip_bots/zulip_bots/bots/dialogflow/test_dialogflow.py @@ -6,14 +6,15 @@ from unittest.mock import patch from zulip_bots.test_lib import BotTestCase, DefaultTests, read_bot_fixture_data -class MockHttplibRequest(): +class MockHttplibRequest: def __init__(self, response: str) -> None: self.response = response def read(self) -> ByteString: return json.dumps(self.response).encode() -class MockTextRequest(): + +class MockTextRequest: def __init__(self) -> None: self.session_id = "" self.query = "" @@ -22,6 +23,7 @@ class MockTextRequest(): def getresponse(self) -> MockHttplibRequest: return MockHttplibRequest(self.response) + @contextmanager def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]: response_data = read_bot_fixture_data(bot_name, test_name) @@ -38,12 +40,14 @@ def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]: mock_text_request.return_value = request yield + class TestDialogFlowBot(BotTestCase, DefaultTests): bot_name = 'dialogflow' def _test(self, test_name: str, message: str, response: str) -> None: - with self.mock_config_info({'key': 'abcdefg', 'bot_info': 'bot info foo bar'}), \ - mock_dialogflow(test_name, 'dialogflow'): + with self.mock_config_info( + {'key': 'abcdefg', 'bot_info': 'bot info foo bar'} + ), mock_dialogflow(test_name, 'dialogflow'): self.verify_reply(message, response) def test_normal(self) -> None: diff --git a/zulip_bots/zulip_bots/bots/dropbox_share/dropbox_share.py b/zulip_bots/zulip_bots/bots/dropbox_share/dropbox_share.py index f2c454a..b221abd 100644 --- a/zulip_bots/zulip_bots/bots/dropbox_share/dropbox_share.py +++ b/zulip_bots/zulip_bots/bots/dropbox_share/dropbox_share.py @@ -7,6 +7,7 @@ from zulip_bots.lib import BotHandler URL = "[{name}](https://www.dropbox.com/home{path})" + class DropboxHandler: ''' This bot allows you to easily share, search and upload files @@ -28,6 +29,7 @@ class DropboxHandler: msg = dbx_command(self.client, command) bot_handler.send_reply(message, msg) + def get_help() -> str: return ''' Example commands: @@ -44,6 +46,7 @@ def get_help() -> str: ``` ''' + def get_usage_examples() -> str: return ''' Usage: @@ -61,15 +64,17 @@ def get_usage_examples() -> str: ``` ''' + REGEXES = dict( command='(ls|mkdir|read|rm|write|search|usage|help)', path=r'(\S+)', optional_path=r'(\S*)', some_text='(.+?)', folder=r'?(?:--fd (\S+))?', - max_results=r'?(?:--mr (\d+))?' + max_results=r'?(?:--mr (\d+))?', ) + def get_commands() -> Dict[str, Tuple[Any, List[str]]]: return { 'help': (dbx_help, ['command']), @@ -83,12 +88,13 @@ def get_commands() -> Dict[str, Tuple[Any, List[str]]]: 'usage': (dbx_usage, []), } + def dbx_command(client: Any, cmd: str) -> str: cmd = cmd.strip() if cmd == 'help': return get_help() cmd_name = cmd.split()[0] - cmd_args = cmd[len(cmd_name):].strip() + cmd_args = cmd[len(cmd_name) :].strip() commands = get_commands() if cmd_name not in commands: return 'ERROR: unrecognized command\n' + get_help() @@ -102,6 +108,7 @@ def dbx_command(client: Any, cmd: str) -> str: else: return 'ERROR: ' + syntax_help(cmd_name) + def syntax_help(cmd_name: str) -> str: commands = get_commands() f, arg_names = commands[cmd_name] @@ -112,23 +119,29 @@ def syntax_help(cmd_name: str) -> str: cmd = cmd_name return 'syntax: {}'.format(cmd) + def dbx_help(client: Any, cmd_name: str) -> str: return syntax_help(cmd_name) + def dbx_usage(client: Any) -> str: return get_usage_examples() + def dbx_mkdir(client: Any, fn: str) -> str: fn = '/' + fn # foo/boo -> /foo/boo try: result = client.files_create_folder(fn) msg = "CREATED FOLDER: " + URL.format(name=result.name, path=result.path_lower) except Exception: - msg = "Please provide a correct folder path and name.\n"\ - "Usage: `mkdir ` to create a folder." + msg = ( + "Please provide a correct folder path and name.\n" + "Usage: `mkdir ` to create a folder." + ) return msg + def dbx_ls(client: Any, fn: str) -> str: if fn != '': fn = '/' + fn @@ -144,12 +157,15 @@ def dbx_ls(client: Any, fn: str) -> str: msg = '`No files available`' except Exception: - msg = "Please provide a correct folder path\n"\ - "Usage: `ls ` to list folders in directory\n"\ - "or simply `ls` for listing folders in the root directory" + msg = ( + "Please provide a correct folder path\n" + "Usage: `ls ` to list folders in directory\n" + "or simply `ls` for listing folders in the root directory" + ) return msg + def dbx_rm(client: Any, fn: str) -> str: fn = '/' + fn @@ -157,10 +173,13 @@ def dbx_rm(client: Any, fn: str) -> str: result = client.files_delete(fn) msg = "DELETED File/Folder : " + URL.format(name=result.name, path=result.path_lower) except Exception: - msg = "Please provide a correct folder path and name.\n"\ - "Usage: `rm ` to delete a folder in root directory." + msg = ( + "Please provide a correct folder path and name.\n" + "Usage: `rm ` to delete a folder in root directory." + ) return msg + def dbx_write(client: Any, fn: str, content: str) -> str: fn = '/' + fn @@ -172,6 +191,7 @@ def dbx_write(client: Any, fn: str, content: str) -> str: return msg + def dbx_read(client: Any, fn: str) -> str: fn = '/' + fn @@ -179,10 +199,13 @@ def dbx_read(client: Any, fn: str) -> str: result = client.files_download(fn) msg = "**{}** :\n{}".format(result[0].name, result[1].text) except Exception: - msg = "Please provide a correct file path\nUsage: `read ` to read content of a file" + msg = ( + "Please provide a correct file path\nUsage: `read ` to read content of a file" + ) return msg + def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str: if folder is None: folder = '' @@ -201,17 +224,22 @@ def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str: msg = '\n'.join(msg_list) except Exception: - msg = "Usage: `search query --mr 10 --fd `\n"\ - "Note:`--mr ` is optional and is used to specify maximun results.\n"\ - " `--fd ` to search in specific folder." + msg = ( + "Usage: `search query --mr 10 --fd `\n" + "Note:`--mr ` is optional and is used to specify maximun results.\n" + " `--fd ` to search in specific folder." + ) if msg == '': - msg = "No files/folders found matching your query.\n"\ - "For file name searching, the last token is used for prefix matching"\ - " (i.e. “bat c” matches “bat cave” but not “batman car”)." + msg = ( + "No files/folders found matching your query.\n" + "For file name searching, the last token is used for prefix matching" + " (i.e. “bat c” matches “bat cave” but not “batman car”)." + ) return msg + def dbx_share(client: Any, fn: str): fn = '/' + fn try: @@ -222,4 +250,5 @@ def dbx_share(client: Any, fn: str): return msg + handler_class = DropboxHandler diff --git a/zulip_bots/zulip_bots/bots/dropbox_share/test_dropbox_share.py b/zulip_bots/zulip_bots/bots/dropbox_share/test_dropbox_share.py index ec1cfe8..ba93e28 100644 --- a/zulip_bots/zulip_bots/bots/dropbox_share/test_dropbox_share.py +++ b/zulip_bots/zulip_bots/bots/dropbox_share/test_dropbox_share.py @@ -13,50 +13,49 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests def get_root_files_list(*args, **kwargs): return MockListFolderResult( - entries = [ - MockFileMetadata('foo', '/foo'), - MockFileMetadata('boo', '/boo') - ], - has_more = False + entries=[MockFileMetadata('foo', '/foo'), MockFileMetadata('boo', '/boo')], has_more=False ) + def get_folder_files_list(*args, **kwargs): return MockListFolderResult( - entries = [ + entries=[ MockFileMetadata('moo', '/foo/moo'), MockFileMetadata('noo', '/foo/noo'), ], - has_more = False + has_more=False, ) + def get_empty_files_list(*args, **kwargs): - return MockListFolderResult( - entries = [], - has_more = False - ) + return MockListFolderResult(entries=[], has_more=False) + def create_file(*args, **kwargs): return MockFileMetadata('foo', '/foo') + def download_file(*args, **kwargs): return [MockFileMetadata('foo', '/foo'), MockHttpResponse('boo')] + def search_files(*args, **kwargs): - return MockSearchResult([ - MockSearchMatch( - MockFileMetadata('foo', '/foo') - ), - MockSearchMatch( - MockFileMetadata('fooboo', '/fooboo') - ) - ]) + return MockSearchResult( + [ + MockSearchMatch(MockFileMetadata('foo', '/foo')), + MockSearchMatch(MockFileMetadata('fooboo', '/fooboo')), + ] + ) + def get_empty_search_result(*args, **kwargs): return MockSearchResult([]) + def get_shared_link(*args, **kwargs): return MockPathLinkMetadata('http://www.foo.com/boo') + def get_help() -> str: return ''' Example commands: @@ -73,6 +72,7 @@ def get_help() -> str: ``` ''' + class TestDropboxBot(BotTestCase, DefaultTests): bot_name = "dropbox_share" config_info = {"access_token": "1234567890"} @@ -83,116 +83,151 @@ class TestDropboxBot(BotTestCase, DefaultTests): self.verify_reply('help', get_help()) def test_dbx_ls_root(self): - bot_response = " - [foo](https://www.dropbox.com/home/foo)\n"\ - " - [boo](https://www.dropbox.com/home/boo)" - with patch('dropbox.Dropbox.files_list_folder', side_effect=get_root_files_list), \ - self.mock_config_info(self.config_info): + bot_response = ( + " - [foo](https://www.dropbox.com/home/foo)\n" + " - [boo](https://www.dropbox.com/home/boo)" + ) + with patch( + 'dropbox.Dropbox.files_list_folder', side_effect=get_root_files_list + ), self.mock_config_info(self.config_info): self.verify_reply("ls", bot_response) def test_dbx_ls_folder(self): - bot_response = " - [moo](https://www.dropbox.com/home/foo/moo)\n"\ - " - [noo](https://www.dropbox.com/home/foo/noo)" - with patch('dropbox.Dropbox.files_list_folder', side_effect=get_folder_files_list), \ - self.mock_config_info(self.config_info): + bot_response = ( + " - [moo](https://www.dropbox.com/home/foo/moo)\n" + " - [noo](https://www.dropbox.com/home/foo/noo)" + ) + with patch( + 'dropbox.Dropbox.files_list_folder', side_effect=get_folder_files_list + ), self.mock_config_info(self.config_info): self.verify_reply("ls foo", bot_response) def test_dbx_ls_empty(self): bot_response = '`No files available`' - with patch('dropbox.Dropbox.files_list_folder', side_effect=get_empty_files_list), \ - self.mock_config_info(self.config_info): + with patch( + 'dropbox.Dropbox.files_list_folder', side_effect=get_empty_files_list + ), self.mock_config_info(self.config_info): self.verify_reply("ls", bot_response) def test_dbx_ls_error(self): - bot_response = "Please provide a correct folder path\n"\ - "Usage: `ls ` to list folders in directory\n"\ - "or simply `ls` for listing folders in the root directory" - with patch('dropbox.Dropbox.files_list_folder', side_effect=Exception()), \ - self.mock_config_info(self.config_info): + bot_response = ( + "Please provide a correct folder path\n" + "Usage: `ls ` to list folders in directory\n" + "or simply `ls` for listing folders in the root directory" + ) + with patch( + 'dropbox.Dropbox.files_list_folder', side_effect=Exception() + ), self.mock_config_info(self.config_info): self.verify_reply("ls", bot_response) def test_dbx_mkdir(self): bot_response = "CREATED FOLDER: [foo](https://www.dropbox.com/home/foo)" - with patch('dropbox.Dropbox.files_create_folder', side_effect=create_file), \ - self.mock_config_info(self.config_info): + with patch( + 'dropbox.Dropbox.files_create_folder', side_effect=create_file + ), self.mock_config_info(self.config_info): self.verify_reply('mkdir foo', bot_response) def test_dbx_mkdir_error(self): - bot_response = "Please provide a correct folder path and name.\n"\ - "Usage: `mkdir ` to create a folder." - with patch('dropbox.Dropbox.files_create_folder', side_effect=Exception()), \ - self.mock_config_info(self.config_info): + bot_response = ( + "Please provide a correct folder path and name.\n" + "Usage: `mkdir ` to create a folder." + ) + with patch( + 'dropbox.Dropbox.files_create_folder', side_effect=Exception() + ), self.mock_config_info(self.config_info): self.verify_reply('mkdir foo/bar', bot_response) def test_dbx_rm(self): bot_response = "DELETED File/Folder : [foo](https://www.dropbox.com/home/foo)" - with patch('dropbox.Dropbox.files_delete', side_effect=create_file), \ - self.mock_config_info(self.config_info): + with patch('dropbox.Dropbox.files_delete', side_effect=create_file), self.mock_config_info( + self.config_info + ): self.verify_reply('rm foo', bot_response) def test_dbx_rm_error(self): - bot_response = "Please provide a correct folder path and name.\n"\ - "Usage: `rm ` to delete a folder in root directory." - with patch('dropbox.Dropbox.files_delete', side_effect=Exception()), \ - self.mock_config_info(self.config_info): + bot_response = ( + "Please provide a correct folder path and name.\n" + "Usage: `rm ` to delete a folder in root directory." + ) + with patch('dropbox.Dropbox.files_delete', side_effect=Exception()), self.mock_config_info( + self.config_info + ): self.verify_reply('rm foo', bot_response) def test_dbx_write(self): bot_response = "Written to file: [foo](https://www.dropbox.com/home/foo)" - with patch('dropbox.Dropbox.files_upload', side_effect=create_file), \ - self.mock_config_info(self.config_info): + with patch('dropbox.Dropbox.files_upload', side_effect=create_file), self.mock_config_info( + self.config_info + ): self.verify_reply('write foo boo', bot_response) def test_dbx_write_error(self): - bot_response = "Incorrect file path or file already exists.\nUsage: `write CONTENT`" - with patch('dropbox.Dropbox.files_upload', side_effect=Exception()), \ - self.mock_config_info(self.config_info): + bot_response = ( + "Incorrect file path or file already exists.\nUsage: `write CONTENT`" + ) + with patch('dropbox.Dropbox.files_upload', side_effect=Exception()), self.mock_config_info( + self.config_info + ): self.verify_reply('write foo boo', bot_response) def test_dbx_read(self): bot_response = "**foo** :\nboo" - with patch('dropbox.Dropbox.files_download', side_effect=download_file), \ - self.mock_config_info(self.config_info): + with patch( + 'dropbox.Dropbox.files_download', side_effect=download_file + ), self.mock_config_info(self.config_info): self.verify_reply('read foo', bot_response) def test_dbx_read_error(self): - bot_response = "Please provide a correct file path\n"\ - "Usage: `read ` to read content of a file" - with patch('dropbox.Dropbox.files_download', side_effect=Exception()), \ - self.mock_config_info(self.config_info): + bot_response = ( + "Please provide a correct file path\n" + "Usage: `read ` to read content of a file" + ) + with patch( + 'dropbox.Dropbox.files_download', side_effect=Exception() + ), self.mock_config_info(self.config_info): self.verify_reply('read foo', bot_response) def test_dbx_search(self): bot_response = " - [foo](https://www.dropbox.com/home/foo)\n - [fooboo](https://www.dropbox.com/home/fooboo)" - with patch('dropbox.Dropbox.files_search', side_effect=search_files), \ - self.mock_config_info(self.config_info): + with patch('dropbox.Dropbox.files_search', side_effect=search_files), self.mock_config_info( + self.config_info + ): self.verify_reply('search foo', bot_response) def test_dbx_search_empty(self): - bot_response = "No files/folders found matching your query.\n"\ - "For file name searching, the last token is used for prefix matching"\ - " (i.e. “bat c” matches “bat cave” but not “batman car”)." - with patch('dropbox.Dropbox.files_search', side_effect=get_empty_search_result), \ - self.mock_config_info(self.config_info): + bot_response = ( + "No files/folders found matching your query.\n" + "For file name searching, the last token is used for prefix matching" + " (i.e. “bat c” matches “bat cave” but not “batman car”)." + ) + with patch( + 'dropbox.Dropbox.files_search', side_effect=get_empty_search_result + ), self.mock_config_info(self.config_info): self.verify_reply('search boo --fd foo', bot_response) def test_dbx_search_error(self): - bot_response = "Usage: `search query --mr 10 --fd `\n"\ - "Note:`--mr ` is optional and is used to specify maximun results.\n"\ - " `--fd ` to search in specific folder." - with patch('dropbox.Dropbox.files_search', side_effect=Exception()), \ - self.mock_config_info(self.config_info): + bot_response = ( + "Usage: `search query --mr 10 --fd `\n" + "Note:`--mr ` is optional and is used to specify maximun results.\n" + " `--fd ` to search in specific folder." + ) + with patch('dropbox.Dropbox.files_search', side_effect=Exception()), self.mock_config_info( + self.config_info + ): self.verify_reply('search foo', bot_response) def test_dbx_share(self): bot_response = 'http://www.foo.com/boo' - with patch('dropbox.Dropbox.sharing_create_shared_link', side_effect=get_shared_link), \ - self.mock_config_info(self.config_info): + with patch( + 'dropbox.Dropbox.sharing_create_shared_link', side_effect=get_shared_link + ), self.mock_config_info(self.config_info): self.verify_reply('share boo', bot_response) def test_dbx_share_error(self): bot_response = "Please provide a correct file name.\nUsage: `share `" - with patch('dropbox.Dropbox.sharing_create_shared_link', side_effect=Exception()), \ - self.mock_config_info(self.config_info): + with patch( + 'dropbox.Dropbox.sharing_create_shared_link', side_effect=Exception() + ), self.mock_config_info(self.config_info): self.verify_reply('share boo', bot_response) def test_dbx_help(self): diff --git a/zulip_bots/zulip_bots/bots/dropbox_share/test_util.py b/zulip_bots/zulip_bots/bots/dropbox_share/test_util.py index 9745013..2a30d4b 100644 --- a/zulip_bots/zulip_bots/bots/dropbox_share/test_util.py +++ b/zulip_bots/zulip_bots/bots/dropbox_share/test_util.py @@ -6,23 +6,28 @@ class MockFileMetadata: self.name = name self.path_lower = path_lower + class MockListFolderResult: def __init__(self, entries: str, has_more: str): self.entries = entries self.has_more = has_more + class MockSearchMatch: def __init__(self, metadata: List[MockFileMetadata]): self.metadata = metadata + class MockSearchResult: def __init__(self, matches: List[MockSearchMatch]): self.matches = matches + class MockPathLinkMetadata: def __init__(self, url: str): self.url = url + class MockHttpResponse: def __init__(self, text: str): self.text = text diff --git a/zulip_bots/zulip_bots/bots/encrypt/encrypt.py b/zulip_bots/zulip_bots/bots/encrypt/encrypt.py index 1548c2f..69fa479 100755 --- a/zulip_bots/zulip_bots/bots/encrypt/encrypt.py +++ b/zulip_bots/zulip_bots/bots/encrypt/encrypt.py @@ -20,6 +20,7 @@ def encrypt(text: str) -> str: return newtext + class EncryptHandler: ''' This bot allows users to quickly encrypt messages using ROT13 encryption. @@ -43,4 +44,5 @@ class EncryptHandler: send_content = "Encrypted/Decrypted text: " + temp_content return send_content + handler_class = EncryptHandler diff --git a/zulip_bots/zulip_bots/bots/file_uploader/file_uploader.py b/zulip_bots/zulip_bots/bots/file_uploader/file_uploader.py index c6bdc44..758baad 100644 --- a/zulip_bots/zulip_bots/bots/file_uploader/file_uploader.py +++ b/zulip_bots/zulip_bots/bots/file_uploader/file_uploader.py @@ -40,4 +40,5 @@ class FileUploaderHandler: uploaded_file_reply = '[{}]({})'.format(path.name, upload['uri']) bot_handler.send_reply(message, uploaded_file_reply) + handler_class = FileUploaderHandler diff --git a/zulip_bots/zulip_bots/bots/file_uploader/test_file_uploader.py b/zulip_bots/zulip_bots/bots/file_uploader/test_file_uploader.py index 2669c2e..54889a2 100644 --- a/zulip_bots/zulip_bots/bots/file_uploader/test_file_uploader.py +++ b/zulip_bots/zulip_bots/bots/file_uploader/test_file_uploader.py @@ -15,20 +15,28 @@ class TestFileUploaderBot(BotTestCase, DefaultTests): @patch('pathlib.Path.is_file', return_value=True) def test_file_upload_failed(self, is_file: Mock, resolve: Mock) -> None: server_reply = dict(result='', msg='error') - with patch('zulip_bots.test_lib.StubBotHandler.upload_file_from_path', - return_value=server_reply): - self.verify_reply('file.txt', 'Failed to upload `{}` file: error'.format(Path('file.txt').resolve())) + with patch( + 'zulip_bots.test_lib.StubBotHandler.upload_file_from_path', return_value=server_reply + ): + self.verify_reply( + 'file.txt', 'Failed to upload `{}` file: error'.format(Path('file.txt').resolve()) + ) @patch('pathlib.Path.resolve', return_value=Path('/file.txt')) @patch('pathlib.Path.is_file', return_value=True) def test_file_upload_success(self, is_file: Mock, resolve: Mock) -> None: server_reply = dict(result='success', uri='https://file/uri') - with patch('zulip_bots.test_lib.StubBotHandler.upload_file_from_path', - return_value=server_reply): + with patch( + 'zulip_bots.test_lib.StubBotHandler.upload_file_from_path', return_value=server_reply + ): self.verify_reply('file.txt', '[file.txt](https://file/uri)') def test_help(self): - self.verify_reply('help', - ('Use this bot with any of the following commands:' - '\n* `@uploader ` : Upload a file, where `` is the path to the file' - '\n* `@uploader help` : Display help message')) + self.verify_reply( + 'help', + ( + 'Use this bot with any of the following commands:' + '\n* `@uploader ` : Upload a file, where `` is the path to the file' + '\n* `@uploader help` : Display help message' + ), + ) diff --git a/zulip_bots/zulip_bots/bots/flock/flock.py b/zulip_bots/zulip_bots/bots/flock/flock.py index 5e3b8af..c4748d0 100644 --- a/zulip_bots/zulip_bots/bots/flock/flock.py +++ b/zulip_bots/zulip_bots/bots/flock/flock.py @@ -21,6 +21,7 @@ def find_recipient_id(users: List[Any], recipient_name: str) -> str: if recipient_name == user['firstName']: return user['id'] + # Make request to given flock URL and return a two-element tuple # whose left-hand value contains JSON body of response (or None if request failed) # and whose right-hand value contains an error message (or None if request succeeded) @@ -34,14 +35,15 @@ def make_flock_request(url: str, params: Dict[str, str]) -> Tuple[Any, str]: right now.\nPlease try again later" return (None, error) + # Returns two-element tuple whose left-hand value contains recipient # user's ID (or None if it was not found) and right-hand value contains # an error message (or None if recipient user's ID was found) -def get_recipient_id(recipient_name: str, config: Dict[str, str]) -> Tuple[Optional[str], Optional[str]]: +def get_recipient_id( + recipient_name: str, config: Dict[str, str] +) -> Tuple[Optional[str], Optional[str]]: token = config['token'] - payload = { - 'token': token - } + payload = {'token': token} users, error = make_flock_request(USERS_LIST_URL, payload) if users is None: return (None, error) @@ -53,6 +55,7 @@ def get_recipient_id(recipient_name: str, config: Dict[str, str]) -> Tuple[Optio else: return (recipient_id, None) + # This handles the message sending work. def get_flock_response(content: str, config: Dict[str, str]) -> str: token = config['token'] @@ -67,11 +70,7 @@ def get_flock_response(content: str, config: Dict[str, str]) -> str: if len(str(recipient_id)) > 30: return "Found user is invalid." - payload = { - 'to': recipient_id, - 'text': message, - 'token': token - } + payload = {'to': recipient_id, 'text': message, 'token': token} res, error = make_flock_request(SEND_MESSAGE_URL, payload) if res is None: return error @@ -81,6 +80,7 @@ def get_flock_response(content: str, config: Dict[str, str]) -> str: else: return "Message sending failed :slightly_frowning_face:. Please try again." + def get_flock_bot_response(content: str, config: Dict[str, str]) -> None: content = content.strip() if content == '' or content == 'help': @@ -89,6 +89,7 @@ def get_flock_bot_response(content: str, config: Dict[str, str]) -> None: result = get_flock_response(content, config) return result + class FlockHandler: ''' This is flock bot. Now you can send messages to any of your @@ -106,4 +107,5 @@ right from Zulip.''' response = get_flock_bot_response(message['content'], self.config_info) bot_handler.send_reply(message, response) + handler_class = FlockHandler diff --git a/zulip_bots/zulip_bots/bots/flock/test_flock.py b/zulip_bots/zulip_bots/bots/flock/test_flock.py index e77ccf2..5dfca8d 100644 --- a/zulip_bots/zulip_bots/bots/flock/test_flock.py +++ b/zulip_bots/zulip_bots/bots/flock/test_flock.py @@ -9,11 +9,7 @@ class TestFlockBot(BotTestCase, DefaultTests): bot_name = "flock" normal_config = {"token": "12345"} - message_config = { - "token": "12345", - "text": "Ricky: test message", - "to": "u:somekey" - } + message_config = {"token": "12345", "text": "Ricky: test message", "to": "u:somekey"} help_message = ''' You can send messages to any Flock user associated with your account from Zulip. @@ -27,52 +23,62 @@ You can send messages to any Flock user associated with your account from Zulip. self.verify_reply('', self.help_message) def test_fetch_id_connection_error(self) -> None: - with self.mock_config_info(self.normal_config), \ - patch('requests.get', side_effect=ConnectionError()), \ - patch('logging.exception'): - self.verify_reply('tyler: Hey tyler', "Uh-Oh, couldn\'t process the request \ -right now.\nPlease try again later") + with self.mock_config_info(self.normal_config), patch( + 'requests.get', side_effect=ConnectionError() + ), patch('logging.exception'): + self.verify_reply( + 'tyler: Hey tyler', + "Uh-Oh, couldn\'t process the request \ +right now.\nPlease try again later", + ) def test_response_connection_error(self) -> None: - with self.mock_config_info(self.message_config), \ - patch('requests.get', side_effect=ConnectionError()), \ - patch('logging.exception'): - self.verify_reply('Ricky: test message', "Uh-Oh, couldn\'t process the request \ -right now.\nPlease try again later") + with self.mock_config_info(self.message_config), patch( + 'requests.get', side_effect=ConnectionError() + ), patch('logging.exception'): + self.verify_reply( + 'Ricky: test message', + "Uh-Oh, couldn\'t process the request \ +right now.\nPlease try again later", + ) def test_no_recipient_found(self) -> None: bot_response = "No user found. Make sure you typed it correctly." - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_no_recipient_found'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_no_recipient_found' + ): self.verify_reply('david: hello', bot_response) def test_found_invalid_recipient(self) -> None: bot_response = "Found user is invalid." - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_found_invalid_recipient'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_found_invalid_recipient' + ): self.verify_reply('david: hello', bot_response) @patch('zulip_bots.bots.flock.flock.get_recipient_id') def test_message_send_connection_error(self, get_recipient_id: str) -> None: bot_response = "Uh-Oh, couldn't process the request right now.\nPlease try again later" get_recipient_id.return_value = ["u:userid", None] - with self.mock_config_info(self.normal_config), \ - patch('requests.get', side_effect=ConnectionError()), \ - patch('logging.exception'): + with self.mock_config_info(self.normal_config), patch( + 'requests.get', side_effect=ConnectionError() + ), patch('logging.exception'): self.verify_reply('Rishabh: hi there', bot_response) @patch('zulip_bots.bots.flock.flock.get_recipient_id') def test_message_send_success(self, get_recipient_id: str) -> None: bot_response = "Message sent." get_recipient_id.return_value = ["u:userid", None] - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_message_send_success'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_message_send_success' + ): self.verify_reply('Rishabh: hi there', bot_response) @patch('zulip_bots.bots.flock.flock.get_recipient_id') def test_message_send_failed(self, get_recipient_id: str) -> None: bot_response = "Message sending failed :slightly_frowning_face:. Please try again." get_recipient_id.return_value = ["u:invalid", None] - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_message_send_failed'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_message_send_failed' + ): self.verify_reply('Rishabh: hi there', bot_response) diff --git a/zulip_bots/zulip_bots/bots/followup/followup.py b/zulip_bots/zulip_bots/bots/followup/followup.py index ee4d070..3abe73b 100644 --- a/zulip_bots/zulip_bots/bots/followup/followup.py +++ b/zulip_bots/zulip_bots/bots/followup/followup.py @@ -32,18 +32,22 @@ class FollowupHandler: def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: if message['content'] == '': - bot_response = "Please specify the message you want to send to followup stream after @mention-bot" + bot_response = ( + "Please specify the message you want to send to followup stream after @mention-bot" + ) bot_handler.send_reply(message, bot_response) elif message['content'] == 'help': bot_handler.send_reply(message, self.usage()) else: bot_response = self.get_bot_followup_response(message) - bot_handler.send_message(dict( - type='stream', - to=self.stream, - subject=message['sender_email'], - content=bot_response, - )) + bot_handler.send_message( + dict( + type='stream', + to=self.stream, + subject=message['sender_email'], + content=bot_response, + ) + ) def get_bot_followup_response(self, message: Dict[str, str]) -> str: original_content = message['content'] @@ -53,4 +57,5 @@ class FollowupHandler: return new_content + handler_class = FollowupHandler diff --git a/zulip_bots/zulip_bots/bots/followup/test_followup.py b/zulip_bots/zulip_bots/bots/followup/test_followup.py index dbb4e38..6548e97 100755 --- a/zulip_bots/zulip_bots/bots/followup/test_followup.py +++ b/zulip_bots/zulip_bots/bots/followup/test_followup.py @@ -31,7 +31,9 @@ class TestFollowUpBot(BotTestCase, DefaultTests): self.assertEqual(response['to'], 'issue') def test_bot_responds_to_empty_message(self) -> None: - bot_response = 'Please specify the message you want to send to followup stream after @mention-bot' + bot_response = ( + 'Please specify the message you want to send to followup stream after @mention-bot' + ) with self.mock_config_info({'stream': 'followup'}): self.verify_reply('', bot_response) diff --git a/zulip_bots/zulip_bots/bots/front/front.py b/zulip_bots/zulip_bots/bots/front/front.py index 1c8a42c..8458ffa 100644 --- a/zulip_bots/zulip_bots/bots/front/front.py +++ b/zulip_bots/zulip_bots/bots/front/front.py @@ -13,7 +13,7 @@ class FrontHandler: ('delete', "Delete a conversation."), ('spam', "Mark a conversation as spam."), ('open', "Restore a conversation."), - ('comment ', "Leave a comment.") + ('comment ', "Leave a comment."), ] CNV_ID_REGEXP = 'cnv_(?P[0-9a-z]+)' COMMENT_PREFIX = "comment " @@ -40,9 +40,11 @@ class FrontHandler: return response def archive(self, bot_handler: BotHandler) -> str: - response = requests.patch(self.FRONT_API.format(self.conversation_id), - headers={"Authorization": self.auth}, - json={"status": "archived"}) + response = requests.patch( + self.FRONT_API.format(self.conversation_id), + headers={"Authorization": self.auth}, + json={"status": "archived"}, + ) if response.status_code not in (200, 204): return "Something went wrong." @@ -50,9 +52,11 @@ class FrontHandler: return "Conversation was archived." def delete(self, bot_handler: BotHandler) -> str: - response = requests.patch(self.FRONT_API.format(self.conversation_id), - headers={"Authorization": self.auth}, - json={"status": "deleted"}) + response = requests.patch( + self.FRONT_API.format(self.conversation_id), + headers={"Authorization": self.auth}, + json={"status": "deleted"}, + ) if response.status_code not in (200, 204): return "Something went wrong." @@ -60,9 +64,11 @@ class FrontHandler: return "Conversation was deleted." def spam(self, bot_handler: BotHandler) -> str: - response = requests.patch(self.FRONT_API.format(self.conversation_id), - headers={"Authorization": self.auth}, - json={"status": "spam"}) + response = requests.patch( + self.FRONT_API.format(self.conversation_id), + headers={"Authorization": self.auth}, + json={"status": "spam"}, + ) if response.status_code not in (200, 204): return "Something went wrong." @@ -70,9 +76,11 @@ class FrontHandler: return "Conversation was marked as spam." def restore(self, bot_handler: BotHandler) -> str: - response = requests.patch(self.FRONT_API.format(self.conversation_id), - headers={"Authorization": self.auth}, - json={"status": "open"}) + response = requests.patch( + self.FRONT_API.format(self.conversation_id), + headers={"Authorization": self.auth}, + json={"status": "open"}, + ) if response.status_code not in (200, 204): return "Something went wrong." @@ -80,8 +88,11 @@ class FrontHandler: return "Conversation was restored." def comment(self, bot_handler: BotHandler, **kwargs: Any) -> str: - response = requests.post(self.FRONT_API.format(self.conversation_id) + "/comments", - headers={"Authorization": self.auth}, json=kwargs) + response = requests.post( + self.FRONT_API.format(self.conversation_id) + "/comments", + headers={"Authorization": self.auth}, + json=kwargs, + ) if response.status_code not in (200, 201): return "Something went wrong." @@ -93,9 +104,12 @@ class FrontHandler: result = re.search(self.CNV_ID_REGEXP, message['subject']) if not result: - bot_handler.send_reply(message, "No coversation ID found. Please make " - "sure that the name of the topic " - "contains a valid coversation ID.") + bot_handler.send_reply( + message, + "No coversation ID found. Please make " + "sure that the name of the topic " + "contains a valid coversation ID.", + ) return None self.conversation_id = result.group() @@ -118,10 +132,11 @@ class FrontHandler: elif command.startswith(self.COMMENT_PREFIX): kwargs = { 'author_id': "alt:email:" + message['sender_email'], - 'body': command[len(self.COMMENT_PREFIX):] + 'body': command[len(self.COMMENT_PREFIX) :], } bot_handler.send_reply(message, self.comment(bot_handler, **kwargs)) else: bot_handler.send_reply(message, "Unknown command. Use `help` for instructions.") + handler_class = FrontHandler diff --git a/zulip_bots/zulip_bots/bots/front/test_front.py b/zulip_bots/zulip_bots/bots/front/test_front.py index 7a8182f..fc388a6 100644 --- a/zulip_bots/zulip_bots/bots/front/test_front.py +++ b/zulip_bots/zulip_bots/bots/front/test_front.py @@ -24,11 +24,14 @@ class TestFrontBot(BotTestCase, DefaultTests): def test_help(self) -> None: with self.mock_config_info({'api_key': "TEST"}): - self.verify_reply('help', "`archive` Archive a conversation.\n" - "`delete` Delete a conversation.\n" - "`spam` Mark a conversation as spam.\n" - "`open` Restore a conversation.\n" - "`comment ` Leave a comment.\n") + self.verify_reply( + 'help', + "`archive` Archive a conversation.\n" + "`delete` Delete a conversation.\n" + "`spam` Mark a conversation as spam.\n" + "`open` Restore a conversation.\n" + "`comment ` Leave a comment.\n", + ) def test_archive(self) -> None: with self.mock_config_info({'api_key': "TEST"}): @@ -94,6 +97,9 @@ class TestFrontBotWrongTopic(BotTestCase, DefaultTests): def test_no_conversation_id(self) -> None: with self.mock_config_info({'api_key': "TEST"}): - self.verify_reply('archive', "No coversation ID found. Please make " - "sure that the name of the topic " - "contains a valid coversation ID.") + self.verify_reply( + 'archive', + "No coversation ID found. Please make " + "sure that the name of the topic " + "contains a valid coversation ID.", + ) diff --git a/zulip_bots/zulip_bots/bots/game_handler_bot/game_handler_bot.py b/zulip_bots/zulip_bots/bots/game_handler_bot/game_handler_bot.py index 8bd6a8e..454b25d 100644 --- a/zulip_bots/zulip_bots/bots/game_handler_bot/game_handler_bot.py +++ b/zulip_bots/zulip_bots/bots/game_handler_bot/game_handler_bot.py @@ -26,12 +26,7 @@ class MockModel: def __init__(self) -> None: self.current_board = 'mock board' - def make_move( - self, - move: str, - player: int, - is_computer: bool = False - ) -> Any: + def make_move(self, move: str, player: int, is_computer: bool = False) -> Any: if not is_computer: if int(move.replace('move ', '')) < 9: return 'mock board' @@ -67,7 +62,7 @@ class GameHandlerBotHandler(GameAdapter): gameMessageHandler, rules, max_players=2, - supports_computer=True + supports_computer=True, ) diff --git a/zulip_bots/zulip_bots/bots/game_handler_bot/test_game_handler_bot.py b/zulip_bots/zulip_bots/bots/game_handler_bot/test_game_handler_bot.py index 0588fec..f9519e0 100644 --- a/zulip_bots/zulip_bots/bots/game_handler_bot/test_game_handler_bot.py +++ b/zulip_bots/zulip_bots/bots/game_handler_bot/test_game_handler_bot.py @@ -16,7 +16,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): user_name: str = 'foo', type: str = 'private', stream: str = '', - subject: str = '' + subject: str = '', ) -> Dict[str, str]: message = dict( sender_email=user, @@ -38,7 +38,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): user_name: str = 'foo', stream: str = '', subject: str = '', - max_messages: int = 20 + max_messages: int = 20, ) -> None: ''' This function serves a similar purpose @@ -52,15 +52,12 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): _b, bot_handler = self._get_handlers() type = 'private' if stream == '' else 'stream' message = self.make_request_message( - request, user_name + '@example.com', user_name, type, stream, subject) + request, user_name + '@example.com', user_name, type, stream, subject + ) bot_handler.reset_transcript() bot.handle_message(message, bot_handler) - responses = [ - message - for (method, message) - in bot_handler.transcript - ] + responses = [message for (method, message) in bot_handler.transcript] first_response = responses[response_number] self.assertEqual(expected_response, first_response['content']) self.assertLessEqual(len(responses), max_messages) @@ -70,12 +67,19 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): bot, bot_handler = self._get_handlers() message = { 'sender_email': '{}@example.com'.format(name), - 'sender_full_name': '{}'.format(name) + 'sender_full_name': '{}'.format(name), } bot.add_user_to_cache(message) return bot - def setup_game(self, id: str = '', bot: Any = None, players: List[str] = ['foo', 'baz'], subject: str = 'test game', stream: str = 'test') -> Any: + def setup_game( + self, + id: str = '', + bot: Any = None, + players: List[str] = ['foo', 'baz'], + subject: str = 'test game', + stream: str = 'test', + ) -> Any: if bot is None: bot, bot_handler = self._get_handlers() for p in players: @@ -84,8 +88,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): game_id = 'abc123' if id != '': game_id = id - instance = GameInstance(bot, False, subject, - game_id, players_emails, stream) + instance = GameInstance(bot, False, subject, game_id, players_emails, stream) bot.instances.update({game_id: instance}) instance.turn = -1 instance.start() @@ -95,8 +98,9 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): bot = self.add_user_to_cache('foo') bot.email = 'test-bot@example.com' self.add_user_to_cache('test-bot', bot) - instance = GameInstance(bot, False, 'test game', 'abc123', [ - 'foo@example.com', 'test-bot@example.com'], 'test') + instance = GameInstance( + bot, False, 'test game', 'abc123', ['foo@example.com', 'test-bot@example.com'], 'test' + ) bot.instances.update({'abc123': instance}) instance.start() return bot @@ -132,41 +136,55 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): self.verify_response('foo bar baz', self.help_message(), 0) def test_exception_handling(self) -> None: - with patch('logging.exception'), \ - patch('zulip_bots.game_handler.GameAdapter.command_quit', - side_effect=Exception): + with patch('logging.exception'), patch( + 'zulip_bots.game_handler.GameAdapter.command_quit', side_effect=Exception + ): self.verify_response('quit', 'Error .', 0) def test_not_in_game_messages(self) -> None: self.verify_response( - 'move 3', 'You are not in a game at the moment. Type `help` for help.', 0, max_messages=1) + 'move 3', + 'You are not in a game at the moment. Type `help` for help.', + 0, + max_messages=1, + ) self.verify_response( - 'quit', 'You are not in a game. Type `help` for all commands.', 0, max_messages=1) + 'quit', 'You are not in a game. Type `help` for all commands.', 0, max_messages=1 + ) def test_start_game_with_name(self) -> None: bot = self.add_user_to_cache('baz') - self.verify_response('start game with @**baz**', - 'You\'ve sent an invitation to play foo test game with @**baz**', 1, bot=bot) + self.verify_response( + 'start game with @**baz**', + 'You\'ve sent an invitation to play foo test game with @**baz**', + 1, + bot=bot, + ) self.assertEqual(len(bot.invites), 1) def test_start_game_with_email(self) -> None: bot = self.add_user_to_cache('baz') - self.verify_response('start game with baz@example.com', - 'You\'ve sent an invitation to play foo test game with @**baz**', 1, bot=bot) + self.verify_response( + 'start game with baz@example.com', + 'You\'ve sent an invitation to play foo test game with @**baz**', + 1, + bot=bot, + ) self.assertEqual(len(bot.invites), 1) def test_join_game_and_start_in_stream(self) -> None: bot = self.add_user_to_cache('baz') self.add_user_to_cache('foo', bot) - bot.invites = { - 'abc': { - 'stream': 'test', - 'subject': 'test game', - 'host': 'foo@example.com' - } - } - self.verify_response('join', '@**baz** has joined the game', 0, bot=bot, - stream='test', subject='test game', user_name='baz') + bot.invites = {'abc': {'stream': 'test', 'subject': 'test game', 'host': 'foo@example.com'}} + self.verify_response( + 'join', + '@**baz** has joined the game', + 0, + bot=bot, + stream='test', + subject='test game', + user_name='baz', + ) self.assertEqual(len(bot.instances.keys()), 1) def test_start_game_in_stream(self) -> None: @@ -175,7 +193,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): '**foo** wants to play **foo test game**. Type @**test-bot** join to play them!', 0, stream='test', - subject='test game' + subject='test game', ) def test_start_invite_game_in_stream(self) -> None: @@ -186,12 +204,19 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): 2, bot=bot, stream='test', - subject='game test' + subject='game test', ) def test_join_no_game(self) -> None: - self.verify_response('join', 'There is not a game in this subject. Type `help` for all commands.', - 0, stream='test', subject='test game', user_name='baz', max_messages=1) + self.verify_response( + 'join', + 'There is not a game in this subject. Type `help` for all commands.', + 0, + stream='test', + subject='test game', + user_name='baz', + max_messages=1, + ) def test_accept_invitation(self) -> None: bot = self.add_user_to_cache('baz') @@ -201,80 +226,96 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): 'subject': '###private###', 'stream': 'games', 'host': 'foo@example.com', - 'baz@example.com': 'p' + 'baz@example.com': 'p', } } self.verify_response( - 'accept', 'Accepted invitation to play **foo test game** from @**foo**.', 0, bot, 'baz') + 'accept', 'Accepted invitation to play **foo test game** from @**foo**.', 0, bot, 'baz' + ) def test_decline_invitation(self) -> None: bot = self.add_user_to_cache('baz') self.add_user_to_cache('foo', bot) bot.invites = { - 'abc': { - 'subject': '###private###', - 'host': 'foo@example.com', - 'baz@example.com': 'p' - } + 'abc': {'subject': '###private###', 'host': 'foo@example.com', 'baz@example.com': 'p'} } self.verify_response( - 'decline', 'Declined invitation to play **foo test game** from @**foo**.', 0, bot, 'baz') + 'decline', 'Declined invitation to play **foo test game** from @**foo**.', 0, bot, 'baz' + ) def test_quit_invite(self) -> None: bot = self.add_user_to_cache('foo') - bot.invites = { - 'abc': { - 'subject': '###private###', - 'host': 'foo@example.com' - } - } - self.verify_response( - 'quit', 'Game cancelled.\n**foo** quit.', 0, bot, 'foo') + bot.invites = {'abc': {'subject': '###private###', 'host': 'foo@example.com'}} + self.verify_response('quit', 'Game cancelled.\n**foo** quit.', 0, bot, 'foo') def test_user_already_in_game_errors(self) -> None: bot = self.setup_game() - self.verify_response('start game with @**baz**', - 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1) self.verify_response( - 'start game', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, stream='test', max_messages=1) + 'start game with @**baz**', + 'You are already in a game. Type `quit` to leave.', + 0, + bot=bot, + max_messages=1, + ) self.verify_response( - 'accept', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1) + 'start game', + 'You are already in a game. Type `quit` to leave.', + 0, + bot=bot, + stream='test', + max_messages=1, + ) self.verify_response( - 'decline', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1) + 'accept', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1 + ) self.verify_response( - 'join', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1) + 'decline', + 'You are already in a game. Type `quit` to leave.', + 0, + bot=bot, + max_messages=1, + ) + self.verify_response( + 'join', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1 + ) def test_register_command(self) -> None: bot = self.add_user_to_cache('foo') - self.verify_response( - 'register', 'Hello @**foo**. Thanks for registering!', 0, bot, 'foo') + self.verify_response('register', 'Hello @**foo**. Thanks for registering!', 0, bot, 'foo') self.assertIn('foo@example.com', bot.user_cache.keys()) def test_no_active_invite_errors(self) -> None: - self.verify_response( - 'accept', 'No active invites. Type `help` for commands.', 0) - self.verify_response( - 'decline', 'No active invites. Type `help` for commands.', 0) + self.verify_response('accept', 'No active invites. Type `help` for commands.', 0) + self.verify_response('decline', 'No active invites. Type `help` for commands.', 0) def test_wrong_number_of_players_message(self) -> None: bot = self.add_user_to_cache('baz') bot.min_players = 5 - self.verify_response('start game with @**baz**', - 'You must have at least 5 players to play.\nGame cancelled.', 0, bot=bot) + self.verify_response( + 'start game with @**baz**', + 'You must have at least 5 players to play.\nGame cancelled.', + 0, + bot=bot, + ) bot.min_players = 2 bot.max_players = 1 - self.verify_response('start game with @**baz**', - 'The maximum number of players for this game is 1.', 0, bot=bot) + self.verify_response( + 'start game with @**baz**', + 'The maximum number of players for this game is 1.', + 0, + bot=bot, + ) bot.max_players = 1 - bot.invites = { - 'abc': { - 'stream': 'test', - 'subject': 'test game', - 'host': 'foo@example.com' - } - } - self.verify_response('join', 'This game is full.', 0, bot=bot, - stream='test', subject='test game', user_name='baz') + bot.invites = {'abc': {'stream': 'test', 'subject': 'test game', 'host': 'foo@example.com'}} + self.verify_response( + 'join', + 'This game is full.', + 0, + bot=bot, + stream='test', + subject='test game', + user_name='baz', + ) def test_public_accept(self) -> None: bot = self.add_user_to_cache('baz') @@ -284,70 +325,125 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): 'stream': 'test', 'subject': 'test game', 'host': 'baz@example.com', - 'foo@example.com': 'p' + 'foo@example.com': 'p', } } - self.verify_response('accept', '@**foo** has accepted the invitation.', - 0, bot=bot, stream='test', subject='test game') + self.verify_response( + 'accept', + '@**foo** has accepted the invitation.', + 0, + bot=bot, + stream='test', + subject='test game', + ) def test_start_game_with_computer(self) -> None: - self.verify_response('start game with @**test-bot**', - 'Wait... That\'s me!', 4, stream='test', subject='test game') + self.verify_response( + 'start game with @**test-bot**', + 'Wait... That\'s me!', + 4, + stream='test', + subject='test game', + ) def test_sent_by_bot(self) -> None: with self.assertRaises(IndexError): self.verify_response( - 'foo', '', 0, user_name='test-bot', stream='test', subject='test game') + 'foo', '', 0, user_name='test-bot', stream='test', subject='test game' + ) def test_forfeit(self) -> None: bot = self.setup_game() - self.verify_response('forfeit', '**foo** forfeited!', - 0, bot=bot, stream='test', subject='test game') + self.verify_response( + 'forfeit', '**foo** forfeited!', 0, bot=bot, stream='test', subject='test game' + ) def test_draw(self) -> None: bot = self.setup_game() - self.verify_response('draw', '**foo** has voted for a draw!\nType `draw` to accept', - 0, bot=bot, stream='test', subject='test game') - self.verify_response('draw', 'It was a draw!', 0, bot=bot, stream='test', - subject='test game', user_name='baz') + self.verify_response( + 'draw', + '**foo** has voted for a draw!\nType `draw` to accept', + 0, + bot=bot, + stream='test', + subject='test game', + ) + self.verify_response( + 'draw', + 'It was a draw!', + 0, + bot=bot, + stream='test', + subject='test game', + user_name='baz', + ) def test_normal_turns(self) -> None: bot = self.setup_game() - self.verify_response('move 3', '**foo** moved in column 3\n\nfoo\n\nIt\'s **baz**\'s (:red_circle:) turn.', - 0, bot=bot, stream='test', subject='test game') - self.verify_response('move 3', '**baz** moved in column 3\n\nfoo\n\nIt\'s **foo**\'s (:blue_circle:) turn.', - 0, bot=bot, stream='test', subject='test game', user_name='baz') + self.verify_response( + 'move 3', + '**foo** moved in column 3\n\nfoo\n\nIt\'s **baz**\'s (:red_circle:) turn.', + 0, + bot=bot, + stream='test', + subject='test game', + ) + self.verify_response( + 'move 3', + '**baz** moved in column 3\n\nfoo\n\nIt\'s **foo**\'s (:blue_circle:) turn.', + 0, + bot=bot, + stream='test', + subject='test game', + user_name='baz', + ) def test_wrong_turn(self) -> None: bot = self.setup_game() - self.verify_response('move 5', 'It\'s **foo**\'s (:blue_circle:) turn.', 0, - bot=bot, stream='test', subject='test game', user_name='baz') + self.verify_response( + 'move 5', + 'It\'s **foo**\'s (:blue_circle:) turn.', + 0, + bot=bot, + stream='test', + subject='test game', + user_name='baz', + ) def test_private_message_error(self) -> None: self.verify_response( - 'start game', 'If you are starting a game in private messages, you must invite players. Type `help` for commands.', 0, max_messages=1) + 'start game', + 'If you are starting a game in private messages, you must invite players. Type `help` for commands.', + 0, + max_messages=1, + ) bot = self.add_user_to_cache('bar') bot.invites = { - 'abcdefg': { - 'host': 'bar@example.com', - 'stream': 'test', - 'subject': 'test game' - } + 'abcdefg': {'host': 'bar@example.com', 'stream': 'test', 'subject': 'test game'} } self.verify_response( - 'join', 'You cannot join games in private messages. Type `help` for all commands.', 0, bot=bot, max_messages=1) + 'join', + 'You cannot join games in private messages. Type `help` for all commands.', + 0, + bot=bot, + max_messages=1, + ) def test_game_already_in_subject(self) -> None: bot = self.add_user_to_cache('foo') bot.invites = { - 'abcdefg': { - 'host': 'foo@example.com', - 'stream': 'test', - 'subject': 'test game' - } + 'abcdefg': {'host': 'foo@example.com', 'stream': 'test', 'subject': 'test game'} } - self.verify_response('start game', 'There is already a game in this stream.', 0, - bot=bot, stream='test', subject='test game', user_name='baz', max_messages=1) + self.verify_response( + 'start game', + 'There is already a game in this stream.', + 0, + bot=bot, + stream='test', + subject='test game', + user_name='baz', + max_messages=1, + ) # def test_not_authorized(self) -> None: # bot = self.setup_game() @@ -355,32 +451,46 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): # user_name='bar', stream='test', subject='test game', max_messages=1) def test_unknown_user(self) -> None: - self.verify_response('start game with @**bar**', - 'I don\'t know @**bar**. Tell them to say @**test-bot** register', 0) - self.verify_response('start game with bar@example.com', - 'I don\'t know bar@example.com. Tell them to use @**test-bot** register', 0) + self.verify_response( + 'start game with @**bar**', + 'I don\'t know @**bar**. Tell them to say @**test-bot** register', + 0, + ) + self.verify_response( + 'start game with bar@example.com', + 'I don\'t know bar@example.com. Tell them to use @**test-bot** register', + 0, + ) def test_is_user_not_player(self) -> None: bot = self.add_user_to_cache('foo') self.add_user_to_cache('baz', bot) - bot.invites = { - 'abcdefg': { - 'host': 'foo@example.com', - 'baz@example.com': 'a' - } - } + bot.invites = {'abcdefg': {'host': 'foo@example.com', 'baz@example.com': 'a'}} self.assertFalse(bot.is_user_not_player('foo@example.com')) self.assertFalse(bot.is_user_not_player('baz@example.com')) def test_move_help_message(self) -> None: bot = self.setup_game() - self.verify_response('move 123', '* To make your move during a game, type\n```move ```', - 0, bot=bot, stream='test', subject='test game') + self.verify_response( + 'move 123', + '* To make your move during a game, type\n```move ```', + 0, + bot=bot, + stream='test', + subject='test game', + ) def test_invalid_move_message(self) -> None: bot = self.setup_game() - self.verify_response('move 9', 'Invalid Move.', 0, - bot=bot, stream='test', subject='test game', max_messages=2) + self.verify_response( + 'move 9', + 'Invalid Move.', + 0, + bot=bot, + stream='test', + subject='test game', + max_messages=2, + ) def test_get_game_id_by_email(self) -> None: bot = self.setup_game() @@ -389,9 +499,13 @@ class TestGameHandlerBot(BotTestCase, DefaultTests): def test_game_over_and_leaderboard(self) -> None: bot = self.setup_game() bot.put_user_cache() - with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='foo@example.com'): - self.verify_response('move 3', '**foo** won! :tada:', - 1, bot=bot, stream='test', subject='test game') + with patch( + 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', + return_value='foo@example.com', + ): + self.verify_response( + 'move 3', '**foo** won! :tada:', 1, bot=bot, stream='test', subject='test game' + ) leaderboard = '**Most wins**\n\n\ Player | Games Won | Games Drawn | Games Lost | Total Games\n\ --- | --- | --- | --- | --- \n\ @@ -402,27 +516,54 @@ Player | Games Won | Games Drawn | Games Lost | Total Games\n\ def test_current_turn_winner(self) -> None: bot = self.setup_game() - with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='current turn'): - self.verify_response('move 3', '**foo** won! :tada:', - 1, bot=bot, stream='test', subject='test game') + with patch( + 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', + return_value='current turn', + ): + self.verify_response( + 'move 3', '**foo** won! :tada:', 1, bot=bot, stream='test', subject='test game' + ) def test_computer_turn(self) -> None: bot = self.setup_computer_game() - self.verify_response('move 3', '**foo** moved in column 3\n\nfoo\n\nIt\'s **test-bot**\'s (:red_circle:) turn.', - 0, bot=bot, stream='test', subject='test game') - with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='test-bot@example.com'): - self.verify_response('move 5', 'I won! Well Played!', - 2, bot=bot, stream='test', subject='test game') + self.verify_response( + 'move 3', + '**foo** moved in column 3\n\nfoo\n\nIt\'s **test-bot**\'s (:red_circle:) turn.', + 0, + bot=bot, + stream='test', + subject='test game', + ) + with patch( + 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', + return_value='test-bot@example.com', + ): + self.verify_response( + 'move 5', 'I won! Well Played!', 2, bot=bot, stream='test', subject='test game' + ) def test_computer_endgame_responses(self) -> None: bot = self.setup_computer_game() - with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='foo@example.com'): - self.verify_response('move 5', 'You won! Nice!', - 2, bot=bot, stream='test', subject='test game') + with patch( + 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', + return_value='foo@example.com', + ): + self.verify_response( + 'move 5', 'You won! Nice!', 2, bot=bot, stream='test', subject='test game' + ) bot = self.setup_computer_game() - with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='draw'): - self.verify_response('move 5', 'It was a draw! Well Played!', - 2, bot=bot, stream='test', subject='test game') + with patch( + 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', + return_value='draw', + ): + self.verify_response( + 'move 5', + 'It was a draw! Well Played!', + 2, + bot=bot, + stream='test', + subject='test game', + ) def test_add_user_statistics(self) -> None: bot = self.add_user_to_cache('foo') @@ -448,54 +589,82 @@ Player | Games Won | Games Drawn | Games Lost | Total Games\n\ 'host': 'foo@example.com', 'baz@example.com': 'a', 'stream': 'test', - 'subject': 'test game' + 'subject': 'test game', } } - self.assertEqual(bot.get_game_info('abcdefg'), { - 'game_id': 'abcdefg', - 'type': 'invite', - 'stream': 'test', - 'subject': 'test game', - 'players': ['foo@example.com', 'baz@example.com'] - }) + self.assertEqual( + bot.get_game_info('abcdefg'), + { + 'game_id': 'abcdefg', + 'type': 'invite', + 'stream': 'test', + 'subject': 'test game', + 'players': ['foo@example.com', 'baz@example.com'], + }, + ) def test_parse_message(self) -> None: bot = self.setup_game() - self.verify_response('move 3', 'Join your game using the link below!\n\n> **Game `abc123`**\n\ + self.verify_response( + 'move 3', + 'Join your game using the link below!\n\n> **Game `abc123`**\n\ > foo test game\n\ > 2/2 players\n\ -> **[Join Game](/#narrow/stream/test/topic/test game)**', 0, bot=bot) +> **[Join Game](/#narrow/stream/test/topic/test game)**', + 0, + bot=bot, + ) bot = self.setup_game() - self.verify_response('move 3', '''Your current game is not in this subject. \n\ + self.verify_response( + 'move 3', + '''Your current game is not in this subject. \n\ To move subjects, send your message again, otherwise join the game using the link below. > **Game `abc123`** > foo test game > 2/2 players -> **[Join Game](/#narrow/stream/test/topic/test game)**''', 0, bot=bot, stream='test 2', subject='game 2') - self.verify_response('move 3', 'foo', 0, bot=bot, - stream='test 2', subject='game 2') +> **[Join Game](/#narrow/stream/test/topic/test game)**''', + 0, + bot=bot, + stream='test 2', + subject='game 2', + ) + self.verify_response('move 3', 'foo', 0, bot=bot, stream='test 2', subject='game 2') def test_change_game_subject(self) -> None: bot = self.setup_game('abc123') self.setup_game('abcdefg', bot, ['bar', 'abc'], 'test game 2', 'test2') - self.verify_response('move 3', '''Your current game is not in this subject. \n\ + self.verify_response( + 'move 3', + '''Your current game is not in this subject. \n\ To move subjects, send your message again, otherwise join the game using the link below. > **Game `abcdefg`** > foo test game > 2/2 players -> **[Join Game](/#narrow/stream/test2/topic/test game 2)**''', 0, bot=bot, user_name='bar', stream='test game', subject='test2') - self.verify_response('move 3', 'There is already a game in this subject.', - 0, bot=bot, user_name='bar', stream='test game', subject='test') +> **[Join Game](/#narrow/stream/test2/topic/test game 2)**''', + 0, + bot=bot, + user_name='bar', + stream='test game', + subject='test2', + ) + self.verify_response( + 'move 3', + 'There is already a game in this subject.', + 0, + bot=bot, + user_name='bar', + stream='test game', + subject='test', + ) bot.invites = { 'foo bar baz': { 'host': 'foo@example.com', 'baz@example.com': 'a', 'stream': 'test', - 'subject': 'test game' + 'subject': 'test game', } } - bot.change_game_subject('foo bar baz', 'test2', - 'game2', self.make_request_message('foo')) + bot.change_game_subject('foo bar baz', 'test2', 'game2', self.make_request_message('foo')) self.assertEqual(bot.invites['foo bar baz']['stream'], 'test2') diff --git a/zulip_bots/zulip_bots/bots/game_of_fifteen/game_of_fifteen.py b/zulip_bots/zulip_bots/bots/game_of_fifteen/game_of_fifteen.py index fb4bfd9..f53ebbb 100644 --- a/zulip_bots/zulip_bots/bots/game_of_fifteen/game_of_fifteen.py +++ b/zulip_bots/zulip_bots/bots/game_of_fifteen/game_of_fifteen.py @@ -6,13 +6,9 @@ from zulip_bots.game_handler import BadMoveException, GameAdapter class GameOfFifteenModel: - final_board = [[0, 1, 2], - [3, 4, 5], - [6, 7, 8]] + final_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]] - initial_board = [[8, 7, 6], - [5, 4, 3], - [2, 1, 0]] + initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]] def __init__(self, board: Any = None) -> None: if board is not None: @@ -41,7 +37,7 @@ class GameOfFifteenModel: def won(self, board: Any) -> bool: for i in range(3): for j in range(3): - if (board[i][j] != self.final_board[i][j]): + if board[i][j] != self.final_board[i][j]: return False return True @@ -67,23 +63,26 @@ class GameOfFifteenModel: if tile not in coordinates: raise BadMoveException('You can only move tiles which exist in the board.') i, j = coordinates[tile] - if (j-1) > -1 and board[i][j-1] == 0: - board[i][j-1] = tile + if (j - 1) > -1 and board[i][j - 1] == 0: + board[i][j - 1] = tile board[i][j] = 0 - elif (i-1) > -1 and board[i-1][j] == 0: - board[i-1][j] = tile + elif (i - 1) > -1 and board[i - 1][j] == 0: + board[i - 1][j] = tile board[i][j] = 0 - elif (j+1) < 3 and board[i][j+1] == 0: - board[i][j+1] = tile + elif (j + 1) < 3 and board[i][j + 1] == 0: + board[i][j + 1] = tile board[i][j] = 0 - elif (i+1) < 3 and board[i+1][j] == 0: - board[i+1][j] = tile + elif (i + 1) < 3 and board[i + 1][j] == 0: + board[i + 1][j] = tile board[i][j] = 0 else: - raise BadMoveException('You can only move tiles which are adjacent to :grey_question:.') + raise BadMoveException( + 'You can only move tiles which are adjacent to :grey_question:.' + ) if m == moves - 1: return board + class GameOfFifteenMessageHandler: tiles = { @@ -113,8 +112,11 @@ class GameOfFifteenMessageHandler: return original_player + ' moved ' + tile def game_start_message(self) -> str: - return ("Welcome to Game of Fifteen!" - "To make a move, type @-mention `move ...`") + return ( + "Welcome to Game of Fifteen!" + "To make a move, type @-mention `move ...`" + ) + class GameOfFifteenBotHandler(GameAdapter): ''' @@ -125,7 +127,9 @@ class GameOfFifteenBotHandler(GameAdapter): def __init__(self) -> None: game_name = 'Game of Fifteen' bot_name = 'Game of Fifteen' - move_help_message = '* To make your move during a game, type\n```move ...```' + move_help_message = ( + '* To make your move during a game, type\n```move ...```' + ) move_regex = r'move [\d{1}\s]+$' model = GameOfFifteenModel gameMessageHandler = GameOfFifteenMessageHandler @@ -145,4 +149,5 @@ class GameOfFifteenBotHandler(GameAdapter): max_players=1, ) + handler_class = GameOfFifteenBotHandler diff --git a/zulip_bots/zulip_bots/bots/game_of_fifteen/test_game_of_fifteen.py b/zulip_bots/zulip_bots/bots/game_of_fifteen/test_game_of_fifteen.py index c86adf7..ac0f935 100644 --- a/zulip_bots/zulip_bots/bots/game_of_fifteen/test_game_of_fifteen.py +++ b/zulip_bots/zulip_bots/bots/game_of_fifteen/test_game_of_fifteen.py @@ -9,20 +9,19 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests): bot_name = 'game_of_fifteen' def make_request_message( - self, - content: str, - user: str = 'foo@example.com', - user_name: str = 'foo' + self, content: str, user: str = 'foo@example.com', user_name: str = 'foo' ) -> Dict[str, str]: - message = dict( - sender_email=user, - content=content, - sender_full_name=user_name - ) + message = dict(sender_email=user, content=content, sender_full_name=user_name) return message # Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled - def verify_response(self, request: str, expected_response: str, response_number: int, user: str = 'foo@example.com') -> None: + def verify_response( + self, + request: str, + expected_response: str, + response_number: int, + user: str = 'foo@example.com', + ) -> None: ''' This function serves a similar purpose to BotTestCase.verify_dialog, but allows @@ -36,11 +35,7 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests): bot.handle_message(message, bot_handler) - responses = [ - message - for (method, message) - in bot_handler.transcript - ] + responses = [message for (method, message) in bot_handler.transcript] first_response = responses[response_number] self.assertEqual(expected_response, first_response['content']) @@ -63,23 +58,19 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests): def test_game_message_handler_responses(self) -> None: board = '\n\n:grey_question::one::two:\n\n:three::four::five:\n\n:six::seven::eight:' bot, bot_handler = self._get_handlers() - self.assertEqual(bot.gameMessageHandler.parse_board( - self.winning_board), board) - self.assertEqual(bot.gameMessageHandler.alert_move_message( - 'foo', 'move 1'), 'foo moved 1') - self.assertEqual(bot.gameMessageHandler.game_start_message( - ), "Welcome to Game of Fifteen!" - "To make a move, type @-mention `move ...`") + self.assertEqual(bot.gameMessageHandler.parse_board(self.winning_board), board) + self.assertEqual(bot.gameMessageHandler.alert_move_message('foo', 'move 1'), 'foo moved 1') + self.assertEqual( + bot.gameMessageHandler.game_start_message(), + "Welcome to Game of Fifteen!" + "To make a move, type @-mention `move ...`", + ) - winning_board = [[0, 1, 2], - [3, 4, 5], - [6, 7, 8]] + winning_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]] def test_game_of_fifteen_logic(self) -> None: def confirmAvailableMoves( - good_moves: List[int], - bad_moves: List[int], - board: List[List[int]] + good_moves: List[int], bad_moves: List[int], board: List[List[int]] ) -> None: gameOfFifteenModel.update_board(board) for move in good_moves: @@ -92,18 +83,16 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests): tile: str, token_number: int, initial_board: List[List[int]], - final_board: List[List[int]] + final_board: List[List[int]], ) -> None: gameOfFifteenModel.update_board(initial_board) - test_board = gameOfFifteenModel.make_move( - 'move ' + tile, token_number) + test_board = gameOfFifteenModel.make_move('move ' + tile, token_number) self.assertEqual(test_board, final_board) def confirmGameOver(board: List[List[int]], result: str) -> None: gameOfFifteenModel.update_board(board) - game_over = gameOfFifteenModel.determine_game_over( - ['first_player']) + game_over = gameOfFifteenModel.determine_game_over(['first_player']) self.assertEqual(game_over, result) @@ -115,62 +104,43 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests): gameOfFifteenModel = GameOfFifteenModel() # Basic Board setups - initial_board = [[8, 7, 6], - [5, 4, 3], - [2, 1, 0]] + initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]] - sample_board = [[7, 6, 8], - [3, 0, 1], - [2, 4, 5]] + sample_board = [[7, 6, 8], [3, 0, 1], [2, 4, 5]] - winning_board = [[0, 1, 2], - [3, 4, 5], - [6, 7, 8]] + winning_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]] # Test Move Validation Logic confirmAvailableMoves([1, 2, 3, 4, 5, 6, 7, 8], [0, 9, -1], initial_board) # Test Move Logic - confirmMove('1', 0, initial_board, - [[8, 7, 6], - [5, 4, 3], - [2, 0, 1]]) + confirmMove('1', 0, initial_board, [[8, 7, 6], [5, 4, 3], [2, 0, 1]]) - confirmMove('1 2', 0, initial_board, - [[8, 7, 6], - [5, 4, 3], - [0, 2, 1]]) + confirmMove('1 2', 0, initial_board, [[8, 7, 6], [5, 4, 3], [0, 2, 1]]) - confirmMove('1 2 5', 0, initial_board, - [[8, 7, 6], - [0, 4, 3], - [5, 2, 1]]) + confirmMove('1 2 5', 0, initial_board, [[8, 7, 6], [0, 4, 3], [5, 2, 1]]) - confirmMove('1 2 5 4', 0, initial_board, - [[8, 7, 6], - [4, 0, 3], - [5, 2, 1]]) + confirmMove('1 2 5 4', 0, initial_board, [[8, 7, 6], [4, 0, 3], [5, 2, 1]]) - confirmMove('3', 0, sample_board, - [[7, 6, 8], - [0, 3, 1], - [2, 4, 5]]) + confirmMove('3', 0, sample_board, [[7, 6, 8], [0, 3, 1], [2, 4, 5]]) - confirmMove('3 7', 0, sample_board, - [[0, 6, 8], - [7, 3, 1], - [2, 4, 5]]) + confirmMove('3 7', 0, sample_board, [[0, 6, 8], [7, 3, 1], [2, 4, 5]]) # Test coordinates logic: - confirm_coordinates(initial_board, {8: (0, 0), - 7: (0, 1), - 6: (0, 2), - 5: (1, 0), - 4: (1, 1), - 3: (1, 2), - 2: (2, 0), - 1: (2, 1), - 0: (2, 2)}) + confirm_coordinates( + initial_board, + { + 8: (0, 0), + 7: (0, 1), + 6: (0, 2), + 5: (1, 0), + 4: (1, 1), + 3: (1, 2), + 2: (2, 0), + 1: (2, 1), + 0: (2, 2), + }, + ) # Test Game Over Logic: confirmGameOver(winning_board, 'current turn') @@ -183,9 +153,7 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests): move3 = 'move 23' move4 = 'move 0' move5 = 'move 1 2' - initial_board = [[8, 7, 6], - [5, 4, 3], - [2, 1, 0]] + initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]] model.update_board(initial_board) with self.assertRaises(BadMoveException): diff --git a/zulip_bots/zulip_bots/bots/giphy/giphy.py b/zulip_bots/zulip_bots/bots/giphy/giphy.py index 56cee0e..037085d 100644 --- a/zulip_bots/zulip_bots/bots/giphy/giphy.py +++ b/zulip_bots/zulip_bots/bots/giphy/giphy.py @@ -19,6 +19,7 @@ class GiphyHandler: and responds with a message with the GIF based on provided keywords. It also responds to private messages. """ + def usage(self) -> str: return ''' This plugin allows users to post GIFs provided by Giphy. @@ -28,8 +29,7 @@ class GiphyHandler: @staticmethod def validate_config(config_info: Dict[str, str]) -> None: - query = {'s': 'Hello', - 'api_key': config_info['key']} + query = {'s': 'Hello', 'api_key': config_info['key']} try: data = requests.get(GIPHY_TRANSLATE_API, params=query) data.raise_for_status() @@ -38,19 +38,17 @@ class GiphyHandler: except HTTPError as e: error_message = str(e) if data.status_code == 403: - error_message += ('This is likely due to an invalid key.\n' - 'Follow the instructions in doc.md for setting an API key.') + error_message += ( + 'This is likely due to an invalid key.\n' + 'Follow the instructions in doc.md for setting an API key.' + ) raise ConfigValidationError(error_message) def initialize(self, bot_handler: BotHandler) -> None: self.config_info = bot_handler.get_config_info('giphy') def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - bot_response = get_bot_giphy_response( - message, - bot_handler, - self.config_info - ) + bot_response = get_bot_giphy_response(message, bot_handler, self.config_info) bot_handler.send_reply(message, bot_response) @@ -83,21 +81,26 @@ def get_url_gif_giphy(keyword: str, api_key: str) -> Union[int, str]: return gif_url -def get_bot_giphy_response(message: Dict[str, str], bot_handler: BotHandler, config_info: Dict[str, str]) -> str: +def get_bot_giphy_response( + message: Dict[str, str], bot_handler: BotHandler, config_info: Dict[str, str] +) -> str: # Each exception has a specific reply should "gif_url" return a number. # The bot will post the appropriate message for the error. keyword = message['content'] try: gif_url = get_url_gif_giphy(keyword, config_info['key']) except requests.exceptions.ConnectionError: - return ('Uh oh, sorry :slightly_frowning_face:, I ' - 'cannot process your request right now. But, ' - 'let\'s try again later! :grin:') + return ( + 'Uh oh, sorry :slightly_frowning_face:, I ' + 'cannot process your request right now. But, ' + 'let\'s try again later! :grin:' + ) except GiphyNoResultException: - return ('Sorry, I don\'t have a GIF for "%s"! ' - ':astonished:' % (keyword,)) - return ('[Click to enlarge](%s)' - '[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' - % (gif_url,)) + return 'Sorry, I don\'t have a GIF for "%s"! ' ':astonished:' % (keyword,) + return ( + '[Click to enlarge](%s)' + '[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' % (gif_url,) + ) + handler_class = GiphyHandler diff --git a/zulip_bots/zulip_bots/bots/giphy/test_giphy.py b/zulip_bots/zulip_bots/bots/giphy/test_giphy.py index 732d116..244f553 100755 --- a/zulip_bots/zulip_bots/bots/giphy/test_giphy.py +++ b/zulip_bots/zulip_bots/bots/giphy/test_giphy.py @@ -10,25 +10,28 @@ class TestGiphyBot(BotTestCase, DefaultTests): # Test for bot response to empty message def test_bot_responds_to_empty_message(self) -> None: - bot_response = '[Click to enlarge]' \ - '(https://media0.giphy.com/media/ISumMYQyX4sSI/giphy.gif)' \ - '[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' - with self.mock_config_info({'key': '12345678'}), \ - self.mock_http_conversation('test_random'): + bot_response = ( + '[Click to enlarge]' + '(https://media0.giphy.com/media/ISumMYQyX4sSI/giphy.gif)' + '[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' + ) + with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation('test_random'): self.verify_reply('', bot_response) def test_normal(self) -> None: - bot_response = '[Click to enlarge]' \ - '(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)' \ - '[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' + bot_response = ( + '[Click to enlarge]' + '(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)' + '[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' + ) - with self.mock_config_info({'key': '12345678'}), \ - self.mock_http_conversation('test_normal'): + with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation('test_normal'): self.verify_reply('Hello', bot_response) def test_no_result(self) -> None: - with self.mock_config_info({'key': '12345678'}), \ - self.mock_http_conversation('test_no_result'): + with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation( + 'test_no_result' + ): self.verify_reply( 'world without zulip', 'Sorry, I don\'t have a GIF for "world without zulip"! :astonished:', @@ -38,8 +41,9 @@ class TestGiphyBot(BotTestCase, DefaultTests): get_bot_message_handler(self.bot_name) StubBotHandler() with self.mock_http_conversation('test_403'): - self.validate_invalid_config({'key': '12345678'}, - "This is likely due to an invalid key.\n") + self.validate_invalid_config( + {'key': '12345678'}, "This is likely due to an invalid key.\n" + ) def test_connection_error_when_validate_config(self) -> None: error = ConnectionError() @@ -53,11 +57,12 @@ class TestGiphyBot(BotTestCase, DefaultTests): self.validate_valid_config({'key': '12345678'}) def test_connection_error_while_running(self) -> None: - with self.mock_config_info({'key': '12345678'}), \ - patch('requests.get', side_effect=[ConnectionError()]), \ - patch('logging.exception'): + with self.mock_config_info({'key': '12345678'}), patch( + 'requests.get', side_effect=[ConnectionError()] + ), patch('logging.exception'): self.verify_reply( 'world without chocolate', 'Uh oh, sorry :slightly_frowning_face:, I ' 'cannot process your request right now. But, ' - 'let\'s try again later! :grin:') + 'let\'s try again later! :grin:', + ) diff --git a/zulip_bots/zulip_bots/bots/github_detail/github_detail.py b/zulip_bots/zulip_bots/bots/github_detail/github_detail.py index f18b6a1..0c71ad8 100644 --- a/zulip_bots/zulip_bots/bots/github_detail/github_detail.py +++ b/zulip_bots/zulip_bots/bots/github_detail/github_detail.py @@ -22,11 +22,13 @@ class GithubHandler: self.repo = self.config_info.get("repo", False) def usage(self) -> str: - return ("This plugin displays details on github issues and pull requests. " - "To reference an issue or pull request usename mention the bot then " - "anytime in the message type its id, for example:\n" - "@**Github detail** #3212 zulip#3212 zulip/zulip#3212\n" - "The default owner is {} and the default repo is {}.".format(self.owner, self.repo)) + return ( + "This plugin displays details on github issues and pull requests. " + "To reference an issue or pull request usename mention the bot then " + "anytime in the message type its id, for example:\n" + "@**Github detail** #3212 zulip#3212 zulip/zulip#3212\n" + "The default owner is {} and the default repo is {}.".format(self.owner, self.repo) + ) def format_message(self, details: Dict[str, Any]) -> str: number = details['number'] @@ -39,17 +41,24 @@ class GithubHandler: description = details['body'] status = details['state'].title() - message_string = ('**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number), - '({link}) - {title}**\n'.format(title=title, link=link), - 'Created by **[{author}](https://github.com/{author})**\n'.format(author=author), - 'Status - **{status}**\n```quote\n{description}\n```'.format(status=status, description=description)) + message_string = ( + '**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number), + '({link}) - {title}**\n'.format(title=title, link=link), + 'Created by **[{author}](https://github.com/{author})**\n'.format(author=author), + 'Status - **{status}**\n```quote\n{description}\n```'.format( + status=status, description=description + ), + ) return ''.join(message_string) - def get_details_from_github(self, owner: str, repo: str, number: str) -> Union[None, Dict[str, Union[str, int, bool]]]: + def get_details_from_github( + self, owner: str, repo: str, number: str + ) -> Union[None, Dict[str, Union[str, int, bool]]]: # Gets the details of an issues or pull request try: r = requests.get( - self.GITHUB_ISSUE_URL_TEMPLATE.format(owner=owner, repo=repo, id=number)) + self.GITHUB_ISSUE_URL_TEMPLATE.format(owner=owner, repo=repo, id=number) + ) except requests.exceptions.RequestException as e: logging.exception(str(e)) return None @@ -73,8 +82,7 @@ class GithubHandler: return # Capture owner, repo, id - issue_prs = list(re.finditer( - self.HANDLE_MESSAGE_REGEX, message['content'])) + issue_prs = list(re.finditer(self.HANDLE_MESSAGE_REGEX, message['content'])) bot_messages = [] if len(issue_prs) > 5: # We limit to 5 requests to prevent denial-of-service @@ -91,8 +99,11 @@ class GithubHandler: details['repo'] = repo bot_messages.append(self.format_message(details)) else: - bot_messages.append("Failed to find issue/pr: {owner}/{repo}#{id}" - .format(owner=owner, repo=repo, id=issue_pr.group(3))) + bot_messages.append( + "Failed to find issue/pr: {owner}/{repo}#{id}".format( + owner=owner, repo=repo, id=issue_pr.group(3) + ) + ) else: bot_messages.append("Failed to detect owner and repository name.") if len(bot_messages) == 0: @@ -100,4 +111,5 @@ class GithubHandler: bot_message = '\n'.join(bot_messages) bot_handler.send_reply(message, bot_message) + handler_class = GithubHandler diff --git a/zulip_bots/zulip_bots/bots/github_detail/test_github_detail.py b/zulip_bots/zulip_bots/bots/github_detail/test_github_detail.py index 02fb308..a023a93 100755 --- a/zulip_bots/zulip_bots/bots/github_detail/test_github_detail.py +++ b/zulip_bots/zulip_bots/bots/github_detail/test_github_detail.py @@ -23,15 +23,17 @@ class TestGithubDetailBot(BotTestCase, DefaultTests): def test_issue(self) -> None: request = 'zulip/zulip#5365' - bot_response = '**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)'\ - ' - frontend: Enable hot-reloading of CSS in development**\n'\ - 'Created by **[timabbott](https://github.com/timabbott)**\n'\ - 'Status - **Open**\n'\ - '```quote\n'\ - 'There\'s strong interest among folks working on the frontend in being '\ - 'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\n'\ - 'In order to do this, step 1 is to move our CSS minification pipeline '\ - 'from django-pipeline to Webpack. \n```' + bot_response = ( + '**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)' + ' - frontend: Enable hot-reloading of CSS in development**\n' + 'Created by **[timabbott](https://github.com/timabbott)**\n' + 'Status - **Open**\n' + '```quote\n' + 'There\'s strong interest among folks working on the frontend in being ' + 'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\n' + 'In order to do this, step 1 is to move our CSS minification pipeline ' + 'from django-pipeline to Webpack. \n```' + ) with self.mock_http_conversation('test_issue'): with self.mock_config_info(self.mock_config): @@ -39,18 +41,20 @@ class TestGithubDetailBot(BotTestCase, DefaultTests): def test_pull_request(self) -> None: request = 'zulip/zulip#5345' - bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\ - ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\ - 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\ - 'Status - **Open**\n```quote\nAn interaction bug (#4811) '\ - 'between our settings UI and the bootstrap modals breaks hotkey '\ - 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\ - ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\ - ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\ - 'using bootstrap for the account settings modal and all other modals,'\ - ' replace with `Modal` class\r\n[] Add hotkey support for closing the'\ - ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\ - ' removing dependencies from Bootstrap.\n```' + bot_response = ( + '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)' + ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n' + 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n' + 'Status - **Open**\n```quote\nAn interaction bug (#4811) ' + 'between our settings UI and the bootstrap modals breaks hotkey ' + 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]' + ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]' + ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump ' + 'using bootstrap for the account settings modal and all other modals,' + ' replace with `Modal` class\r\n[] Add hotkey support for closing the' + ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in' + ' removing dependencies from Bootstrap.\n```' + ) with self.mock_http_conversation('test_pull'): with self.mock_config_info(self.mock_config): self.verify_reply(request, bot_response) @@ -77,18 +81,22 @@ class TestGithubDetailBot(BotTestCase, DefaultTests): def test_help_text(self) -> None: request = 'help' - bot_response = 'This plugin displays details on github issues and pull requests. '\ - 'To reference an issue or pull request usename mention the bot then '\ - 'anytime in the message type its id, for example:\n@**Github detail** '\ - '#3212 zulip#3212 zulip/zulip#3212\nThe default owner is zulip and '\ - 'the default repo is zulip.' + bot_response = ( + 'This plugin displays details on github issues and pull requests. ' + 'To reference an issue or pull request usename mention the bot then ' + 'anytime in the message type its id, for example:\n@**Github detail** ' + '#3212 zulip#3212 zulip/zulip#3212\nThe default owner is zulip and ' + 'the default repo is zulip.' + ) with self.mock_config_info(self.mock_config): self.verify_reply(request, bot_response) def test_too_many_request(self) -> None: - request = 'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 '\ - 'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1' + request = ( + 'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 ' + 'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1' + ) bot_response = 'Please ask for <=5 links in any one request' with self.mock_config_info(self.mock_config): @@ -102,36 +110,40 @@ class TestGithubDetailBot(BotTestCase, DefaultTests): def test_owner_and_repo_specified_in_config_file(self) -> None: request = '/#5345' - bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\ - ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\ - 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\ - 'Status - **Open**\n```quote\nAn interaction bug (#4811) '\ - 'between our settings UI and the bootstrap modals breaks hotkey '\ - 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\ - ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\ - ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\ - 'using bootstrap for the account settings modal and all other modals,'\ - ' replace with `Modal` class\r\n[] Add hotkey support for closing the'\ - ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\ - ' removing dependencies from Bootstrap.\n```' + bot_response = ( + '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)' + ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n' + 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n' + 'Status - **Open**\n```quote\nAn interaction bug (#4811) ' + 'between our settings UI and the bootstrap modals breaks hotkey ' + 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]' + ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]' + ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump ' + 'using bootstrap for the account settings modal and all other modals,' + ' replace with `Modal` class\r\n[] Add hotkey support for closing the' + ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in' + ' removing dependencies from Bootstrap.\n```' + ) with self.mock_http_conversation('test_pull'): with self.mock_config_info(self.mock_config): self.verify_reply(request, bot_response) def test_owner_and_repo_specified_in_message(self) -> None: request = 'zulip/zulip#5345' - bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\ - ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\ - 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\ - 'Status - **Open**\n```quote\nAn interaction bug (#4811) '\ - 'between our settings UI and the bootstrap modals breaks hotkey '\ - 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\ - ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\ - ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\ - 'using bootstrap for the account settings modal and all other modals,'\ - ' replace with `Modal` class\r\n[] Add hotkey support for closing the'\ - ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\ - ' removing dependencies from Bootstrap.\n```' + bot_response = ( + '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)' + ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n' + 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n' + 'Status - **Open**\n```quote\nAn interaction bug (#4811) ' + 'between our settings UI and the bootstrap modals breaks hotkey ' + 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]' + ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]' + ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump ' + 'using bootstrap for the account settings modal and all other modals,' + ' replace with `Modal` class\r\n[] Add hotkey support for closing the' + ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in' + ' removing dependencies from Bootstrap.\n```' + ) with self.mock_http_conversation('test_pull'): with self.mock_config_info(self.empty_config): self.verify_reply(request, bot_response) diff --git a/zulip_bots/zulip_bots/bots/google_search/google_search.py b/zulip_bots/zulip_bots/bots/google_search/google_search.py index 6ff5b4e..3d99465 100644 --- a/zulip_bots/zulip_bots/bots/google_search/google_search.py +++ b/zulip_bots/zulip_bots/bots/google_search/google_search.py @@ -32,11 +32,11 @@ def google_search(keywords: str) -> List[Dict[str, str]]: if a.text.strip() == 'Cached' and 'webcache.googleusercontent.com' in a['href']: continue # a.text: The name of the page - result = {'url': "https://www.google.com{}".format(link), - 'name': a.text} + result = {'url': "https://www.google.com{}".format(link), 'name': a.text} results.append(result) return results + def get_google_result(search_keywords: str) -> str: help_message = "To use this bot, start messages with @mentioned-bot, \ followed by what you want to search for. If \ @@ -56,13 +56,14 @@ def get_google_result(search_keywords: str) -> str: else: try: results = google_search(search_keywords) - if (len(results) == 0): + if len(results) == 0: return "Found no results." return "Found Result: [{}]({})".format(results[0]['name'], results[0]['url']) except Exception as e: logging.exception(str(e)) return 'Error: Search failed. {}.'.format(e) + class GoogleSearchHandler: ''' This plugin allows users to enter a search @@ -87,4 +88,5 @@ class GoogleSearchHandler: result = get_google_result(original_content) bot_handler.send_reply(message, result) + handler_class = GoogleSearchHandler diff --git a/zulip_bots/zulip_bots/bots/google_search/test_google_search.py b/zulip_bots/zulip_bots/bots/google_search/test_google_search.py index 53c16c7..dbc4b24 100644 --- a/zulip_bots/zulip_bots/bots/google_search/test_google_search.py +++ b/zulip_bots/zulip_bots/bots/google_search/test_google_search.py @@ -11,7 +11,7 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests): with self.mock_http_conversation('test_normal'): self.verify_reply( 'zulip', - 'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)' + 'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)', ) def test_bot_help(self) -> None: @@ -31,9 +31,10 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests): self.verify_reply('no res', 'Found no results.') def test_attribute_error(self) -> None: - with self.mock_http_conversation('test_attribute_error'), \ - patch('logging.exception'): - self.verify_reply('test', 'Error: Search failed. \'NoneType\' object has no attribute \'findAll\'.') + with self.mock_http_conversation('test_attribute_error'), patch('logging.exception'): + self.verify_reply( + 'test', 'Error: Search failed. \'NoneType\' object has no attribute \'findAll\'.' + ) # Makes sure cached results, irrelevant links, or empty results are not displayed def test_ignore_links(self) -> None: @@ -43,5 +44,5 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests): # See test_ignore_links.json self.verify_reply( 'zulip', - 'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)' + 'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)', ) diff --git a/zulip_bots/zulip_bots/bots/google_translate/google_translate.py b/zulip_bots/zulip_bots/bots/google_translate/google_translate.py index 1aeac15..fbed215 100644 --- a/zulip_bots/zulip_bots/bots/google_translate/google_translate.py +++ b/zulip_bots/zulip_bots/bots/google_translate/google_translate.py @@ -10,6 +10,7 @@ class GoogleTranslateHandler: Before using it, make sure you set up google api keys, and enable google cloud translate from the google cloud console. ''' + def usage(self): return ''' This plugin allows users translate messages @@ -27,12 +28,15 @@ class GoogleTranslateHandler: bot_handler.quit(str(e)) def handle_message(self, message, bot_handler): - bot_response = get_translate_bot_response(message['content'], - self.config_info, - message['sender_full_name'], - self.supported_languages) + bot_response = get_translate_bot_response( + message['content'], + self.config_info, + message['sender_full_name'], + self.supported_languages, + ) bot_handler.send_reply(message, bot_response) + api_url = 'https://translation.googleapis.com/language/translate/v2' help_text = ''' @@ -44,17 +48,20 @@ Visit [here](https://cloud.google.com/translate/docs/languages) for all language language_not_found_text = '{} language not found. Visit [here](https://cloud.google.com/translate/docs/languages) for all languages' + def get_supported_languages(key): parameters = {'key': key, 'target': 'en'} - response = requests.get(api_url + '/languages', params = parameters) + response = requests.get(api_url + '/languages', params=parameters) if response.status_code == requests.codes.ok: languages = response.json()['data']['languages'] return {lang['name'].lower(): lang['language'].lower() for lang in languages} raise TranslateError(response.json()['error']['message']) + class TranslateError(Exception): pass + def translate(text_to_translate, key, dest, src): parameters = {'q': text_to_translate, 'target': dest, 'key': key} if src != '': @@ -64,6 +71,7 @@ def translate(text_to_translate, key, dest, src): return response.json()['data']['translations'][0]['translatedText'] raise TranslateError(response.json()['error']['message']) + def get_code_for_language(language, all_languages): if language.lower() not in all_languages.values(): if language.lower() not in all_languages.keys(): @@ -71,6 +79,7 @@ def get_code_for_language(language, all_languages): language = all_languages[language.lower()] return language + def get_translate_bot_response(message_content, config_file, author, all_languages): message_content = message_content.strip() if message_content == 'help' or message_content is None or not message_content.startswith('"'): @@ -94,7 +103,9 @@ def get_translate_bot_response(message_content, config_file, author, all_languag if source_language == '': return language_not_found_text.format("Source") try: - translated_text = translate(text_to_translate, config_file['key'], target_language, source_language) + translated_text = translate( + text_to_translate, config_file['key'], target_language, source_language + ) except requests.exceptions.ConnectionError as conn_err: return "Could not connect to Google Translate. {}.".format(conn_err) except TranslateError as tr_err: @@ -103,4 +114,5 @@ def get_translate_bot_response(message_content, config_file, author, all_languag return "Error. {}.".format(err) return "{} (from {})".format(translated_text, author) + handler_class = GoogleTranslateHandler diff --git a/zulip_bots/zulip_bots/bots/google_translate/test_google_translate.py b/zulip_bots/zulip_bots/bots/google_translate/test_google_translate.py index 5053e41..b51076e 100644 --- a/zulip_bots/zulip_bots/bots/google_translate/test_google_translate.py +++ b/zulip_bots/zulip_bots/bots/google_translate/test_google_translate.py @@ -11,12 +11,14 @@ Please format your message like: Visit [here](https://cloud.google.com/translate/docs/languages) for all languages ''' + class TestGoogleTranslateBot(BotTestCase, DefaultTests): bot_name = "google_translate" def _test(self, message, response, http_config_fixture, http_fixture=None): - with self.mock_config_info({'key': 'abcdefg'}), \ - self.mock_http_conversation(http_config_fixture): + with self.mock_config_info({'key': 'abcdefg'}), self.mock_http_conversation( + http_config_fixture + ): if http_fixture: with self.mock_http_conversation(http_fixture): self.verify_reply(message, response) @@ -27,21 +29,32 @@ class TestGoogleTranslateBot(BotTestCase, DefaultTests): self._test('"hello" de', 'Hallo (from Foo Test User)', 'test_languages', 'test_normal') def test_source_language_not_found(self): - self._test('"hello" german foo', - ('Source language not found. Visit [here]' - '(https://cloud.google.com/translate/docs/languages) for all languages'), - 'test_languages') + self._test( + '"hello" german foo', + ( + 'Source language not found. Visit [here]' + '(https://cloud.google.com/translate/docs/languages) for all languages' + ), + 'test_languages', + ) def test_target_language_not_found(self): - self._test('"hello" bar english', - ('Target language not found. Visit [here]' - '(https://cloud.google.com/translate/docs/languages) for all languages'), - 'test_languages') + self._test( + '"hello" bar english', + ( + 'Target language not found. Visit [here]' + '(https://cloud.google.com/translate/docs/languages) for all languages' + ), + 'test_languages', + ) def test_403(self): - self._test('"hello" german english', - 'Translate Error. Invalid API Key..', - 'test_languages', 'test_403') + self._test( + '"hello" german english', + 'Translate Error. Invalid API Key..', + 'test_languages', + 'test_403', + ) # Override default function in BotTestCase def test_bot_responds_to_empty_message(self): @@ -57,13 +70,17 @@ class TestGoogleTranslateBot(BotTestCase, DefaultTests): self._test('"hello"', help_text, 'test_languages') def test_quotation_in_text(self): - self._test('"this has "quotation" marks in" english', - 'this has "quotation" marks in (from Foo Test User)', - 'test_languages', 'test_quotation') + self._test( + '"this has "quotation" marks in" english', + 'this has "quotation" marks in (from Foo Test User)', + 'test_languages', + 'test_quotation', + ) def test_exception(self): - with patch('zulip_bots.bots.google_translate.google_translate.translate', - side_effect=Exception): + with patch( + 'zulip_bots.bots.google_translate.google_translate.translate', side_effect=Exception + ): self._test('"hello" de', 'Error. .', 'test_languages') def test_invalid_api_key(self): @@ -75,8 +92,5 @@ class TestGoogleTranslateBot(BotTestCase, DefaultTests): self._test(None, None, 'test_api_access_not_configured') def test_connection_error(self): - with patch('requests.post', side_effect=ConnectionError()), \ - patch('logging.warning'): - self._test('"test" en', - 'Could not connect to Google Translate. .', - 'test_languages') + with patch('requests.post', side_effect=ConnectionError()), patch('logging.warning'): + self._test('"test" en', 'Could not connect to Google Translate. .', 'test_languages') diff --git a/zulip_bots/zulip_bots/bots/helloworld/helloworld.py b/zulip_bots/zulip_bots/bots/helloworld/helloworld.py index 5265bd8..a012ec0 100644 --- a/zulip_bots/zulip_bots/bots/helloworld/helloworld.py +++ b/zulip_bots/zulip_bots/bots/helloworld/helloworld.py @@ -19,8 +19,9 @@ class HelloWorldHandler: content = 'beep boop' # type: str bot_handler.send_reply(message, content) - emoji_name = 'wave' # type: str + emoji_name = 'wave' # type: str bot_handler.react(message, emoji_name) return + handler_class = HelloWorldHandler diff --git a/zulip_bots/zulip_bots/bots/help/help.py b/zulip_bots/zulip_bots/bots/help/help.py index 727c06a..96a3bd5 100644 --- a/zulip_bots/zulip_bots/bots/help/help.py +++ b/zulip_bots/zulip_bots/bots/help/help.py @@ -19,4 +19,5 @@ class HelpHandler: help_content = "Info on Zulip can be found here:\nhttps://github.com/zulip/zulip" bot_handler.send_reply(message, help_content) + handler_class = HelpHandler diff --git a/zulip_bots/zulip_bots/bots/help/test_help.py b/zulip_bots/zulip_bots/bots/help/test_help.py index b5a1aa6..22956ef 100755 --- a/zulip_bots/zulip_bots/bots/help/test_help.py +++ b/zulip_bots/zulip_bots/bots/help/test_help.py @@ -8,9 +8,6 @@ class TestHelpBot(BotTestCase, DefaultTests): help_text = "Info on Zulip can be found here:\nhttps://github.com/zulip/zulip" requests = ["", "help", "Hi, my name is abc"] - dialog = [ - (request, help_text) - for request in requests - ] + dialog = [(request, help_text) for request in requests] self.verify_dialog(dialog) diff --git a/zulip_bots/zulip_bots/bots/idonethis/idonethis.py b/zulip_bots/zulip_bots/bots/idonethis/idonethis.py index 35abf40..98a740c 100644 --- a/zulip_bots/zulip_bots/bots/idonethis/idonethis.py +++ b/zulip_bots/zulip_bots/bots/idonethis/idonethis.py @@ -11,24 +11,31 @@ API_BASE_URL = "https://beta.idonethis.com/api/v2" api_key = "" default_team = "" + class AuthenticationException(Exception): pass + class TeamNotFoundException(Exception): def __init__(self, team: str) -> None: self.team = team + class UnknownCommandSyntax(Exception): def __init__(self, detail: str) -> None: self.detail = detail + class UnspecifiedProblemException(Exception): pass -def make_API_request(endpoint: str, - method: str = "GET", - body: Optional[Dict[str, str]] = None, - params: Optional[Dict[str, str]] = None) -> Any: + +def make_API_request( + endpoint: str, + method: str = "GET", + body: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, str]] = None, +) -> Any: headers = {'Authorization': 'Token ' + api_key} if method == "GET": r = requests.get(API_BASE_URL + endpoint, headers=headers, params=params) @@ -36,50 +43,64 @@ def make_API_request(endpoint: str, r = requests.post(API_BASE_URL + endpoint, headers=headers, params=params, json=body) if r.status_code == 200: return r.json() - elif r.status_code == 401 and 'error' in r.json() and r.json()['error'] == "Invalid API Authentication": + elif ( + r.status_code == 401 + and 'error' in r.json() + and r.json()['error'] == "Invalid API Authentication" + ): logging.error('Error authenticating, please check key ' + str(r.url)) raise AuthenticationException() else: logging.error('Error make API request, code ' + str(r.status_code) + '. json: ' + r.json()) raise UnspecifiedProblemException() + def api_noop() -> None: make_API_request("/noop") + def api_list_team() -> List[Dict[str, str]]: return make_API_request("/teams") + def api_show_team(hash_id: str) -> Dict[str, str]: return make_API_request("/teams/{}".format(hash_id)) + # NOTE: This function is not currently used def api_show_users(hash_id: str) -> Any: return make_API_request("/teams/{}/members".format(hash_id)) + def api_list_entries(team_id: Optional[str] = None) -> List[Dict[str, Any]]: if team_id: return make_API_request("/entries", params=dict(team_id=team_id)) else: return make_API_request("/entries") + def api_create_entry(body: str, team_id: str) -> Dict[str, Any]: return make_API_request("/entries", "POST", {"body": body, "team_id": team_id}) + def list_teams() -> str: response = ["Teams:"] + [" * " + team['name'] for team in api_list_team()] return "\n".join(response) + def get_team_hash(team_name: str) -> str: for team in api_list_team(): if team['name'].lower() == team_name.lower() or team['hash_id'] == team_name: return team['hash_id'] raise TeamNotFoundException(team_name) + def team_info(team_name: str) -> str: data = api_show_team(get_team_hash(team_name)) - return "\n".join(["Team Name: {name}", - "ID: `{hash_id}`", - "Created at: {created_at}"]).format(**data) + return "\n".join(["Team Name: {name}", "ID: `{hash_id}`", "Created at: {created_at}"]).format( + **data + ) + def entries_list(team_name: str) -> str: if team_name: @@ -90,18 +111,19 @@ def entries_list(team_name: str) -> str: response = "Entries for all teams:" for entry in data: response += "\n".join( - ["", - " * {body_formatted}", - " * Created at: {created_at}", - " * Status: {status}", - " * User: {username}", - " * Team: {teamname}", - " * ID: {hash_id}" - ]).format(username=entry['user']['full_name'], - teamname=entry['team']['name'], - **entry) + [ + "", + " * {body_formatted}", + " * Created at: {created_at}", + " * Status: {status}", + " * User: {username}", + " * Team: {teamname}", + " * ID: {hash_id}", + ] + ).format(username=entry['user']['full_name'], teamname=entry['team']['name'], **entry) return response + def create_entry(message: str) -> str: SINGLE_WORD_REGEX = re.compile("--team=([a-zA-Z0-9_]*)") MULTIWORD_REGEX = re.compile('"--team=([^"]*)"') @@ -121,14 +143,17 @@ def create_entry(message: str) -> str: team = default_team new_message = message else: - raise UnknownCommandSyntax("""I don't know which team you meant for me to create an entry under. + raise UnknownCommandSyntax( + """I don't know which team you meant for me to create an entry under. Either set a default team or pass the `--team` flag. -More information in my help""") +More information in my help""" + ) team_id = get_team_hash(team) data = api_create_entry(new_message, team_id) return "Great work :thumbs_up:. New entry `{}` created!".format(data['body_formatted']) + class IDoneThisHandler: def initialize(self, bot_handler: BotHandler) -> None: global api_key, default_team @@ -137,18 +162,24 @@ class IDoneThisHandler: api_key = self.config_info['api_key'] else: logging.error("An API key must be specified for this bot to run.") - logging.error("Have a look at the Setup section of my documenation for more information.") + logging.error( + "Have a look at the Setup section of my documenation for more information." + ) bot_handler.quit() if 'default_team' in self.config_info: default_team = self.config_info['default_team'] else: - logging.error("Cannot find default team. Users will need to manually specify a team each time an entry is created.") + logging.error( + "Cannot find default team. Users will need to manually specify a team each time an entry is created." + ) try: api_noop() except AuthenticationException: - logging.error("Authentication exception with idonethis. Can you check that your API keys are correct? ") + logging.error( + "Authentication exception with idonethis. Can you check that your API keys are correct? " + ) bot_handler.quit() except UnspecifiedProblemException: logging.error("Problem connecting to idonethis. Please check connection") @@ -160,7 +191,8 @@ class IDoneThisHandler: default_team_message = "The default team is currently set as `" + default_team + "`." else: default_team_message = "There is currently no default team set up :frowning:." - return ''' + return ( + ''' This bot allows for interaction with idonethis, a collaboration tool to increase a team's productivity. Below are some of the commands you can use, and what they do. @@ -179,7 +211,9 @@ Below are some of the commands you can use, and what they do. Create a new entry. Optionally supply `--team=` for teams with no spaces or `"--team="` for teams with spaces. For example `@mention i did "--team=product team" something` will create a new entry `something` for the product team. - ''' + default_team_message + ''' + + default_team_message + ) def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: bot_handler.send_reply(message, self.get_response(message)) @@ -195,7 +229,9 @@ Below are some of the commands you can use, and what they do. if len(message_content) > 2: reply = team_info(" ".join(message_content[2:])) else: - raise UnknownCommandSyntax("You must specify the team in which you request information from.") + raise UnknownCommandSyntax( + "You must specify the team in which you request information from." + ) elif command in ["entries list", "list entries"]: reply = entries_list(" ".join(message_content[2:])) elif command in ["entries create", "create entry", "new entry", "i did"]: @@ -205,15 +241,21 @@ Below are some of the commands you can use, and what they do. else: raise UnknownCommandSyntax("I can't understand the command you sent me :confused: ") except TeamNotFoundException as e: - reply = "Sorry, it doesn't seem as if I can find a team named `" + e.team + "` :frowning:." + reply = ( + "Sorry, it doesn't seem as if I can find a team named `" + e.team + "` :frowning:." + ) except AuthenticationException: reply = "I can't currently authenticate with idonethis. " reply += "Can you check that your API key is correct? For more information see my documentation." except UnknownCommandSyntax as e: - reply = "Sorry, I don't understand what your trying to say. Use `@mention help` to see my help. " + e.detail + reply = ( + "Sorry, I don't understand what your trying to say. Use `@mention help` to see my help. " + + e.detail + ) except Exception as e: # catches UnspecifiedProblemException, and other problems reply = "Oh dear, I'm having problems processing your request right now. Perhaps you could try again later :grinning:" logging.error("Exception caught: " + str(e)) return reply + handler_class = IDoneThisHandler diff --git a/zulip_bots/zulip_bots/bots/idonethis/test_idonethis.py b/zulip_bots/zulip_bots/bots/idonethis/test_idonethis.py index 5e81def..2524f6e 100644 --- a/zulip_bots/zulip_bots/bots/idonethis/test_idonethis.py +++ b/zulip_bots/zulip_bots/bots/idonethis/test_idonethis.py @@ -7,86 +7,117 @@ class TestIDoneThisBot(BotTestCase, DefaultTests): bot_name = "idonethis" # type: str def test_create_entry_default_team(self) -> None: - with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \ - self.mock_http_conversation('test_create_entry'), \ - self.mock_http_conversation('team_list'): - self.verify_reply('i did something and something else', - 'Great work :thumbs_up:. New entry `something and something else` created!') + with self.mock_config_info( + {'api_key': '12345678', 'default_team': 'testing team 1'} + ), self.mock_http_conversation('test_create_entry'), self.mock_http_conversation( + 'team_list' + ): + self.verify_reply( + 'i did something and something else', + 'Great work :thumbs_up:. New entry `something and something else` created!', + ) def test_create_entry_quoted_team(self) -> None: - with self.mock_config_info({'api_key': '12345678', 'default_team': 'test_team_2'}), \ - self.mock_http_conversation('test_create_entry'), \ - self.mock_http_conversation('team_list'): - self.verify_reply('i did something and something else "--team=testing team 1"', - 'Great work :thumbs_up:. New entry `something and something else` created!') + with self.mock_config_info( + {'api_key': '12345678', 'default_team': 'test_team_2'} + ), self.mock_http_conversation('test_create_entry'), self.mock_http_conversation( + 'team_list' + ): + self.verify_reply( + 'i did something and something else "--team=testing team 1"', + 'Great work :thumbs_up:. New entry `something and something else` created!', + ) def test_create_entry_single_word_team(self) -> None: - with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \ - self.mock_http_conversation('test_create_entry_team_2'), \ - self.mock_http_conversation('team_list'): - self.verify_reply('i did something and something else --team=test_team_2', - 'Great work :thumbs_up:. New entry `something and something else` created!') + with self.mock_config_info( + {'api_key': '12345678', 'default_team': 'testing team 1'} + ), self.mock_http_conversation('test_create_entry_team_2'), self.mock_http_conversation( + 'team_list' + ): + self.verify_reply( + 'i did something and something else --team=test_team_2', + 'Great work :thumbs_up:. New entry `something and something else` created!', + ) def test_bad_key(self) -> None: - with self.mock_config_info({'api_key': '87654321', 'default_team': 'testing team 1'}), \ - self.mock_http_conversation('test_401'), \ - patch('zulip_bots.bots.idonethis.idonethis.api_noop'), \ - patch('logging.error'): - self.verify_reply('list teams', - 'I can\'t currently authenticate with idonethis. Can you check that your API key is correct? ' - 'For more information see my documentation.') + with self.mock_config_info( + {'api_key': '87654321', 'default_team': 'testing team 1'} + ), self.mock_http_conversation('test_401'), patch( + 'zulip_bots.bots.idonethis.idonethis.api_noop' + ), patch( + 'logging.error' + ): + self.verify_reply( + 'list teams', + 'I can\'t currently authenticate with idonethis. Can you check that your API key is correct? ' + 'For more information see my documentation.', + ) def test_list_team(self) -> None: - with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \ - self.mock_http_conversation('team_list'): - self.verify_reply('list teams', - 'Teams:\n * testing team 1\n * test_team_2') + with self.mock_config_info( + {'api_key': '12345678', 'default_team': 'testing team 1'} + ), self.mock_http_conversation('team_list'): + self.verify_reply('list teams', 'Teams:\n * testing team 1\n * test_team_2') def test_show_team_no_team(self) -> None: - with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \ - self.mock_http_conversation('api_noop'): - self.verify_reply('team info', - 'Sorry, I don\'t understand what your trying to say. Use `@mention help` to see my help. ' - 'You must specify the team in which you request information from.') + with self.mock_config_info( + {'api_key': '12345678', 'default_team': 'testing team 1'} + ), self.mock_http_conversation('api_noop'): + self.verify_reply( + 'team info', + 'Sorry, I don\'t understand what your trying to say. Use `@mention help` to see my help. ' + 'You must specify the team in which you request information from.', + ) def test_show_team(self) -> None: - with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \ - self.mock_http_conversation('test_show_team'), \ - patch('zulip_bots.bots.idonethis.idonethis.get_team_hash', return_value='31415926535') as get_team_hashFunction: - self.verify_reply('team info testing team 1', - 'Team Name: testing team 1\n' - 'ID: `31415926535`\n' - 'Created at: 2017-12-28T19:12:55.121+11:00') + with self.mock_config_info( + {'api_key': '12345678', 'default_team': 'testing team 1'} + ), self.mock_http_conversation('test_show_team'), patch( + 'zulip_bots.bots.idonethis.idonethis.get_team_hash', return_value='31415926535' + ) as get_team_hashFunction: + self.verify_reply( + 'team info testing team 1', + 'Team Name: testing team 1\n' + 'ID: `31415926535`\n' + 'Created at: 2017-12-28T19:12:55.121+11:00', + ) get_team_hashFunction.assert_called_with('testing team 1') def test_entries_list(self) -> None: - with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \ - self.mock_http_conversation('test_entries_list'), \ - patch('zulip_bots.bots.idonethis.idonethis.get_team_hash', return_value='31415926535'): - self.verify_reply('entries list testing team 1', - 'Entries for testing team 1:\n' - ' * TESTING\n' - ' * Created at: 2018-01-04T21:10:13.084+11:00\n' - ' * Status: done\n' - ' * User: John Doe\n' - ' * Team: testing team 1\n' - ' * ID: 65e1b21fd8f63adede1daae0bdf28c0e47b84923\n' - ' * Grabbing some more data...\n' - ' * Created at: 2018-01-04T20:07:58.078+11:00\n' - ' * Status: done\n' - ' * User: John Doe\n' - ' * Team: testing team 1\n' - ' * ID: fa974ad8c1acb9e81361a051a697f9dae22908d6\n' - ' * GRABBING HTTP DATA\n' - ' * Created at: 2018-01-04T19:07:17.214+11:00\n' - ' * Status: done\n' - ' * User: John Doe\n' - ' * Team: testing team 1\n' - ' * ID: 72c8241d2218464433268c5abd6625ac104e3d8f') + with self.mock_config_info( + {'api_key': '12345678', 'default_team': 'testing team 1'} + ), self.mock_http_conversation('test_entries_list'), patch( + 'zulip_bots.bots.idonethis.idonethis.get_team_hash', return_value='31415926535' + ): + self.verify_reply( + 'entries list testing team 1', + 'Entries for testing team 1:\n' + ' * TESTING\n' + ' * Created at: 2018-01-04T21:10:13.084+11:00\n' + ' * Status: done\n' + ' * User: John Doe\n' + ' * Team: testing team 1\n' + ' * ID: 65e1b21fd8f63adede1daae0bdf28c0e47b84923\n' + ' * Grabbing some more data...\n' + ' * Created at: 2018-01-04T20:07:58.078+11:00\n' + ' * Status: done\n' + ' * User: John Doe\n' + ' * Team: testing team 1\n' + ' * ID: fa974ad8c1acb9e81361a051a697f9dae22908d6\n' + ' * GRABBING HTTP DATA\n' + ' * Created at: 2018-01-04T19:07:17.214+11:00\n' + ' * Status: done\n' + ' * User: John Doe\n' + ' * Team: testing team 1\n' + ' * ID: 72c8241d2218464433268c5abd6625ac104e3d8f', + ) def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info({'api_key': '12345678', 'bot_info': 'team'}), \ - self.mock_http_conversation('api_noop'): - self.verify_reply('', - 'Sorry, I don\'t understand what your trying to say. Use `@mention help` to see my help. ' - 'I can\'t understand the command you sent me :confused: ') + with self.mock_config_info( + {'api_key': '12345678', 'bot_info': 'team'} + ), self.mock_http_conversation('api_noop'): + self.verify_reply( + '', + 'Sorry, I don\'t understand what your trying to say. Use `@mention help` to see my help. ' + 'I can\'t understand the command you sent me :confused: ', + ) diff --git a/zulip_bots/zulip_bots/bots/incident/incident.py b/zulip_bots/zulip_bots/bots/incident/incident.py index 25dc596..e1588ec 100644 --- a/zulip_bots/zulip_bots/bots/incident/incident.py +++ b/zulip_bots/zulip_bots/bots/incident/incident.py @@ -13,9 +13,11 @@ ANSWERS = { '4': 'escalate', } + class InvalidAnswerException(Exception): pass + class IncidentHandler: def usage(self) -> str: return ''' @@ -43,12 +45,13 @@ class IncidentHandler: bot_response = 'type "new " for a new incident' bot_handler.send_reply(message, bot_response) + def start_new_incident(query: str, message: Dict[str, Any], bot_handler: BotHandler) -> None: # Here is where we would enter the incident in some sort of backend # system. We just simulate everything by having an incident id that # we generate here. - incident = query[len('new '):] + incident = query[len('new ') :] ticket_id = generate_ticket_id(bot_handler.storage) bot_response = format_incident_for_markdown(ticket_id, incident) @@ -56,6 +59,7 @@ def start_new_incident(query: str, message: Dict[str, Any], bot_handler: BotHand bot_handler.send_reply(message, bot_response, widget_content) + def parse_answer(query: str) -> Tuple[str, str]: m = re.match(r'answer\s+(TICKET....)\s+(.)', query) if not m: @@ -74,6 +78,7 @@ def parse_answer(query: str) -> Tuple[str, str]: return (ticket_id, ANSWERS[answer]) + def generate_ticket_id(storage: Any) -> str: try: incident_num = storage.get('ticket_id') @@ -85,6 +90,7 @@ def generate_ticket_id(storage: Any) -> str: ticket_id = 'TICKET%04d' % (incident_num,) return ticket_id + def format_incident_for_widget(ticket_id: str, incident: Dict[str, Any]) -> str: widget_type = 'zform' @@ -116,14 +122,17 @@ def format_incident_for_widget(ticket_id: str, incident: Dict[str, Any]) -> str: payload = json.dumps(widget_content) return payload + def format_incident_for_markdown(ticket_id: str, incident: Dict[str, Any]) -> str: - answer_list = '\n'.join([ - '* **{code}** {answer}'.format( - code=code, - answer=ANSWERS[code], - ) - for code in '1234' - ]) + answer_list = '\n'.join( + [ + '* **{code}** {answer}'.format( + code=code, + answer=ANSWERS[code], + ) + for code in '1234' + ] + ) how_to_respond = '''**reply**: answer {ticket_id} '''.format(ticket_id=ticket_id) content = ''' @@ -139,4 +148,5 @@ Q: {question} ) return content + handler_class = IncidentHandler diff --git a/zulip_bots/zulip_bots/bots/incrementor/incrementor.py b/zulip_bots/zulip_bots/bots/incrementor/incrementor.py index 115f1b8..aec5060 100644 --- a/zulip_bots/zulip_bots/bots/incrementor/incrementor.py +++ b/zulip_bots/zulip_bots/bots/incrementor/incrementor.py @@ -35,10 +35,9 @@ class IncrementorHandler: storage.put('number', num) if storage.get('message_id') is not None: - result = bot_handler.update_message(dict( - message_id=storage.get('message_id'), - content=str(num) - )) + result = bot_handler.update_message( + dict(message_id=storage.get('message_id'), content=str(num)) + ) # When there isn't an error while updating the message, we won't # attempt to send the it again. diff --git a/zulip_bots/zulip_bots/bots/incrementor/test_incrementor.py b/zulip_bots/zulip_bots/bots/incrementor/test_incrementor.py index 103f804..0471c1f 100644 --- a/zulip_bots/zulip_bots/bots/incrementor/test_incrementor.py +++ b/zulip_bots/zulip_bots/bots/incrementor/test_incrementor.py @@ -20,10 +20,7 @@ class TestIncrementorBot(BotTestCase, DefaultTests): bot.handle_message(message, bot_handler) bot.handle_message(message, bot_handler) - content_updates = [ - item[0][0]['content'] - for item in m.call_args_list - ] + content_updates = [item[0][0]['content'] for item in m.call_args_list] self.assertEqual(content_updates, ['2', '3', '4']) def test_bot_edit_timeout(self) -> None: @@ -44,8 +41,5 @@ class TestIncrementorBot(BotTestCase, DefaultTests): # When there is an error, the bot should resend the message with the new value. self.assertEqual(m.call_count, 2) - content_updates = [ - item[0][0]['content'] - for item in m.call_args_list - ] + content_updates = [item[0][0]['content'] for item in m.call_args_list] self.assertEqual(content_updates, ['2', '3']) diff --git a/zulip_bots/zulip_bots/bots/jira/jira.py b/zulip_bots/zulip_bots/bots/jira/jira.py index eefef4a..903e8dc 100644 --- a/zulip_bots/zulip_bots/bots/jira/jira.py +++ b/zulip_bots/zulip_bots/bots/jira/jira.py @@ -145,6 +145,7 @@ Jira Bot: > Issue *BOTS-16* was edited! https://example.atlassian.net/browse/BOTS-16 ''' + class JiraHandler: def usage(self) -> str: return ''' @@ -181,7 +182,8 @@ class JiraHandler: def jql_search(self, jql_query: str) -> str: UNKNOWN_VAL = '*unknown*' jira_response = requests.get( - self.domain_with_protocol + '/rest/api/2/search?jql={}&fields=key,summary,status'.format(jql_query), + self.domain_with_protocol + + '/rest/api/2/search?jql={}&fields=key,summary,status'.format(jql_query), headers={'Authorization': self.auth}, ).json() @@ -197,7 +199,9 @@ class JiraHandler: fields = issue.get('fields', {}) summary = fields.get('summary', UNKNOWN_VAL) status_name = fields.get('status', {}).get('name', UNKNOWN_VAL) - response += "\n - {}: [{}]({}) **[{}]**".format(issue['key'], summary, url + issue['key'], status_name) + response += "\n - {}: [{}]({}) **[{}]**".format( + issue['key'], summary, url + issue['key'], status_name + ) return response @@ -246,20 +250,31 @@ class JiraHandler: ' - Project: *{}*\n' ' - Priority: *{}*\n' ' - Status: *{}*\n' - ).format(key, url, summary, type_name, description, creator_name, project_name, - priority_name, status_name) + ).format( + key, + url, + summary, + type_name, + description, + creator_name, + project_name, + priority_name, + status_name, + ) elif create_match: jira_response = requests.post( self.domain_with_protocol + '/rest/api/2/issue', headers={'Authorization': self.auth}, - json=make_create_json(create_match.group('summary'), - create_match.group('project_key'), - create_match.group('type_name'), - create_match.group('description'), - create_match.group('assignee'), - create_match.group('priority_name'), - create_match.group('labels'), - create_match.group('due_date')) + json=make_create_json( + create_match.group('summary'), + create_match.group('project_key'), + create_match.group('type_name'), + create_match.group('description'), + create_match.group('assignee'), + create_match.group('priority_name'), + create_match.group('labels'), + create_match.group('due_date'), + ), ) jira_response_json = jira_response.json() if jira_response.text else {} @@ -277,14 +292,16 @@ class JiraHandler: jira_response = requests.put( self.domain_with_protocol + '/rest/api/2/issue/' + key, headers={'Authorization': self.auth}, - json=make_edit_json(edit_match.group('summary'), - edit_match.group('project_key'), - edit_match.group('type_name'), - edit_match.group('description'), - edit_match.group('assignee'), - edit_match.group('priority_name'), - edit_match.group('labels'), - edit_match.group('due_date')) + json=make_edit_json( + edit_match.group('summary'), + edit_match.group('project_key'), + edit_match.group('type_name'), + edit_match.group('description'), + edit_match.group('assignee'), + edit_match.group('priority_name'), + edit_match.group('labels'), + edit_match.group('due_date'), + ), ) jira_response_json = jira_response.json() if jira_response.text else {} @@ -310,6 +327,7 @@ class JiraHandler: bot_handler.send_reply(message, response) + def make_jira_auth(username: str, password: str) -> str: '''Makes an auth header for Jira in the form 'Basic: '. @@ -321,10 +339,17 @@ def make_jira_auth(username: str, password: str) -> str: encoded = base64.b64encode(combo.encode('utf-8')).decode('utf-8') return 'Basic ' + encoded -def make_create_json(summary: str, project_key: str, type_name: str, - description: Optional[str], assignee: Optional[str], - priority_name: Optional[str], labels: Optional[str], - due_date: Optional[str]) -> Any: + +def make_create_json( + summary: str, + project_key: str, + type_name: str, + description: Optional[str], + assignee: Optional[str], + priority_name: Optional[str], + labels: Optional[str], + due_date: Optional[str], +) -> Any: '''Makes a JSON string for the Jira REST API editing endpoint based on fields that could be edited. @@ -341,12 +366,8 @@ def make_create_json(summary: str, project_key: str, type_name: str, ''' json_fields = { 'summary': summary, - 'project': { - 'key': project_key - }, - 'issuetype': { - 'name': type_name - } + 'project': {'key': project_key}, + 'issuetype': {'name': type_name}, } if description: json_fields['description'] = description @@ -363,10 +384,17 @@ def make_create_json(summary: str, project_key: str, type_name: str, return json -def make_edit_json(summary: Optional[str], project_key: Optional[str], - type_name: Optional[str], description: Optional[str], - assignee: Optional[str], priority_name: Optional[str], - labels: Optional[str], due_date: Optional[str]) -> Any: + +def make_edit_json( + summary: Optional[str], + project_key: Optional[str], + type_name: Optional[str], + description: Optional[str], + assignee: Optional[str], + priority_name: Optional[str], + labels: Optional[str], + due_date: Optional[str], +) -> Any: '''Makes a JSON string for the Jira REST API editing endpoint based on fields that could be edited. @@ -404,6 +432,7 @@ def make_edit_json(summary: Optional[str], project_key: Optional[str], return json + def check_is_editing_something(match: Any) -> bool: '''Checks if an editing match is actually going to do editing. It is possible for an edit regex to match without doing any editing because each @@ -424,4 +453,5 @@ def check_is_editing_something(match: Any) -> bool: or match.group('due_date') ) + handler_class = JiraHandler diff --git a/zulip_bots/zulip_bots/bots/jira/test_jira.py b/zulip_bots/zulip_bots/bots/jira/test_jira.py index cc1d87b..e1959fa 100644 --- a/zulip_bots/zulip_bots/bots/jira/test_jira.py +++ b/zulip_bots/zulip_bots/bots/jira/test_jira.py @@ -7,20 +7,20 @@ class TestJiraBot(BotTestCase, DefaultTests): MOCK_CONFIG_INFO = { 'username': 'example@example.com', 'password': 'qwerty!123', - 'domain': 'example.atlassian.net' + 'domain': 'example.atlassian.net', } MOCK_SCHEME_CONFIG_INFO = { 'username': 'example@example.com', 'password': 'qwerty!123', - 'domain': 'http://example.atlassian.net' + 'domain': 'http://example.atlassian.net', } MOCK_DISPLAY_CONFIG_INFO = { 'username': 'example@example.com', 'password': 'qwerty!123', 'domain': 'example.atlassian.net', - 'display_url': 'http://test.com' + 'display_url': 'http://test.com', } MOCK_GET_RESPONSE = '''\ @@ -158,8 +158,7 @@ Jira Bot: MOCK_JQL_RESPONSE = '**Search results for "summary ~ TEST"**\n\n*Found 2 results*\n\n\n - TEST-1: [summary test 1](https://example.atlassian.net/browse/TEST-1) **[To Do]**\n - TEST-2: [summary test 2](https://example.atlassian.net/browse/TEST-2) **[To Do]**' def _test_invalid_config(self, invalid_config, error_message) -> None: - with self.mock_config_info(invalid_config), \ - self.assertRaisesRegex(KeyError, error_message): + with self.mock_config_info(invalid_config), self.assertRaisesRegex(KeyError, error_message): bot, bot_handler = self._get_handlers() def test_config_without_username(self) -> None: @@ -167,83 +166,92 @@ Jira Bot: 'password': 'qwerty!123', 'domain': 'example.atlassian.net', } - self._test_invalid_config(config_without_username, - 'No `username` was specified') + self._test_invalid_config(config_without_username, 'No `username` was specified') def test_config_without_password(self) -> None: config_without_password = { 'username': 'example@example.com', 'domain': 'example.atlassian.net', } - self._test_invalid_config(config_without_password, - 'No `password` was specified') + self._test_invalid_config(config_without_password, 'No `password` was specified') def test_config_without_domain(self) -> None: config_without_domain = { 'username': 'example@example.com', 'password': 'qwerty!123', } - self._test_invalid_config(config_without_domain, - 'No `domain` was specified') + self._test_invalid_config(config_without_domain, 'No `domain` was specified') def test_get(self) -> None: - with self.mock_config_info(self.MOCK_CONFIG_INFO), \ - self.mock_http_conversation('test_get'): + with self.mock_config_info(self.MOCK_CONFIG_INFO), self.mock_http_conversation('test_get'): self.verify_reply('get "TEST-13"', self.MOCK_GET_RESPONSE) def test_get_error(self) -> None: - with self.mock_config_info(self.MOCK_CONFIG_INFO), \ - self.mock_http_conversation('test_get_error'): - self.verify_reply('get "TEST-13"', - 'Oh no! Jira raised an error:\n > error1') + with self.mock_config_info(self.MOCK_CONFIG_INFO), self.mock_http_conversation( + 'test_get_error' + ): + self.verify_reply('get "TEST-13"', 'Oh no! Jira raised an error:\n > error1') def test_create(self) -> None: - with self.mock_config_info(self.MOCK_CONFIG_INFO), \ - self.mock_http_conversation('test_create'): - self.verify_reply('create issue "Testing" in project "TEST" with type "Task"', - self.MOCK_CREATE_RESPONSE) + with self.mock_config_info(self.MOCK_CONFIG_INFO), self.mock_http_conversation( + 'test_create' + ): + self.verify_reply( + 'create issue "Testing" in project "TEST" with type "Task"', + self.MOCK_CREATE_RESPONSE, + ) def test_create_error(self) -> None: - with self.mock_config_info(self.MOCK_CONFIG_INFO), \ - self.mock_http_conversation('test_create_error'): - self.verify_reply('create issue "Testing" in project "TEST" with type "Task" ' - 'with description "This is a test description" assigned to "testuser" ' - 'with priority "Medium" labeled "issues, testing" due "2018-06-11"', - 'Oh no! Jira raised an error:\n > error1') + with self.mock_config_info(self.MOCK_CONFIG_INFO), self.mock_http_conversation( + 'test_create_error' + ): + self.verify_reply( + 'create issue "Testing" in project "TEST" with type "Task" ' + 'with description "This is a test description" assigned to "testuser" ' + 'with priority "Medium" labeled "issues, testing" due "2018-06-11"', + 'Oh no! Jira raised an error:\n > error1', + ) def test_edit(self) -> None: - with self.mock_config_info(self.MOCK_CONFIG_INFO), \ - self.mock_http_conversation('test_edit'): - self.verify_reply('edit issue "TEST-16" to use description "description"', - self.MOCK_EDIT_RESPONSE) + with self.mock_config_info(self.MOCK_CONFIG_INFO), self.mock_http_conversation('test_edit'): + self.verify_reply( + 'edit issue "TEST-16" to use description "description"', self.MOCK_EDIT_RESPONSE + ) def test_edit_error(self) -> None: - with self.mock_config_info(self.MOCK_CONFIG_INFO), \ - self.mock_http_conversation('test_edit_error'): - self.verify_reply('edit issue "TEST-13" to use summary "Change the summary" ' - 'to use project "TEST" to use type "Bug" to use description "This is a test description" ' - 'by assigning to "testuser" to use priority "Low" by labeling "issues, testing" ' - 'by making due "2018-06-11"', - 'Oh no! Jira raised an error:\n > error1') + with self.mock_config_info(self.MOCK_CONFIG_INFO), self.mock_http_conversation( + 'test_edit_error' + ): + self.verify_reply( + 'edit issue "TEST-13" to use summary "Change the summary" ' + 'to use project "TEST" to use type "Bug" to use description "This is a test description" ' + 'by assigning to "testuser" to use priority "Low" by labeling "issues, testing" ' + 'by making due "2018-06-11"', + 'Oh no! Jira raised an error:\n > error1', + ) def test_search(self) -> None: - with self.mock_config_info(self.MOCK_CONFIG_INFO), \ - self.mock_http_conversation('test_search'): + with self.mock_config_info(self.MOCK_CONFIG_INFO), self.mock_http_conversation( + 'test_search' + ): self.verify_reply('search "TEST"', self.MOCK_SEARCH_RESPONSE) def test_jql(self) -> None: - with self.mock_config_info(self.MOCK_CONFIG_INFO), \ - self.mock_http_conversation('test_search'): + with self.mock_config_info(self.MOCK_CONFIG_INFO), self.mock_http_conversation( + 'test_search' + ): self.verify_reply('jql "summary ~ TEST"', self.MOCK_JQL_RESPONSE) def test_search_url(self) -> None: - with self.mock_config_info(self.MOCK_DISPLAY_CONFIG_INFO), \ - self.mock_http_conversation('test_search'): + with self.mock_config_info(self.MOCK_DISPLAY_CONFIG_INFO), self.mock_http_conversation( + 'test_search' + ): self.verify_reply('search "TEST"', self.MOCK_SEARCH_RESPONSE_URL) def test_search_scheme(self) -> None: - with self.mock_config_info(self.MOCK_SCHEME_CONFIG_INFO), \ - self.mock_http_conversation('test_search_scheme'): + with self.mock_config_info(self.MOCK_SCHEME_CONFIG_INFO), self.mock_http_conversation( + 'test_search_scheme' + ): self.verify_reply('search "TEST"', self.MOCK_SEARCH_RESPONSE_SCHEME) def test_help(self) -> None: diff --git a/zulip_bots/zulip_bots/bots/link_shortener/link_shortener.py b/zulip_bots/zulip_bots/bots/link_shortener/link_shortener.py index ffe1905..fa6d75f 100644 --- a/zulip_bots/zulip_bots/bots/link_shortener/link_shortener.py +++ b/zulip_bots/zulip_bots/bots/link_shortener/link_shortener.py @@ -15,7 +15,8 @@ class LinkShortenerHandler: return ( 'Mention the link shortener bot in a conversation and then enter ' 'any URLs you want to shorten in the body of the message. \n\n' - '`key` must be set in `link_shortener.conf`.') + '`key` must be set in `link_shortener.conf`.' + ) def initialize(self, bot_handler: BotHandler) -> None: self.config_info = bot_handler.get_config_info('link_shortener') @@ -25,20 +26,25 @@ class LinkShortenerHandler: test_request_data = self.call_link_shorten_service('www.youtube.com/watch') # type: Any try: if self.is_invalid_token_error(test_request_data): - bot_handler.quit('Invalid key. Follow the instructions in doc.md for setting API key.') + bot_handler.quit( + 'Invalid key. Follow the instructions in doc.md for setting API key.' + ) except KeyError: pass def is_invalid_token_error(self, response_json: Any) -> bool: - return response_json['status_code'] == 500 and response_json['status_txt'] == 'INVALID_ARG_ACCESS_TOKEN' + return ( + response_json['status_code'] == 500 + and response_json['status_txt'] == 'INVALID_ARG_ACCESS_TOKEN' + ) def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: REGEX_STR = ( r'(' r'(?:http|https):\/\/' # This allows for the HTTP or HTTPS - # protocol. + # protocol. r'[^"<>\{\}|\^~[\]` ]+' # This allows for any character except - # for certain non-URL-safe ones. + # for certain non-URL-safe ones. r')' ) @@ -51,10 +57,7 @@ class LinkShortenerHandler: content = message['content'] if content.strip() == 'help': - bot_handler.send_reply( - message, - HELP_STR - ) + bot_handler.send_reply(message, HELP_STR) return link_matches = re.findall(REGEX_STR, content) @@ -62,17 +65,13 @@ class LinkShortenerHandler: shortened_links = [self.shorten_link(link) for link in link_matches] link_pairs = [ (link_match + ': ' + shortened_link) - for link_match, shortened_link - in zip(link_matches, shortened_links) + for link_match, shortened_link in zip(link_matches, shortened_links) if shortened_link != '' ] final_response = '\n'.join(link_pairs) if final_response == '': - bot_handler.send_reply( - message, - 'No links found. ' + HELP_STR - ) + bot_handler.send_reply(message, 'No links found. ' + HELP_STR) return bot_handler.send_reply(message, final_response) @@ -95,7 +94,7 @@ class LinkShortenerHandler: def call_link_shorten_service(self, long_url: str) -> Any: response = requests.get( 'https://api-ssl.bitly.com/v3/shorten', - params={'access_token': self.config_info['key'], 'longUrl': long_url} + params={'access_token': self.config_info['key'], 'longUrl': long_url}, ) return response.json() @@ -105,4 +104,5 @@ class LinkShortenerHandler: def get_shorten_url(self, response_json: Any) -> str: return response_json['data']['url'] + handler_class = LinkShortenerHandler diff --git a/zulip_bots/zulip_bots/bots/link_shortener/test_link_shortener.py b/zulip_bots/zulip_bots/bots/link_shortener/test_link_shortener.py index cc5dbb7..bc7702c 100644 --- a/zulip_bots/zulip_bots/bots/link_shortener/test_link_shortener.py +++ b/zulip_bots/zulip_bots/bots/link_shortener/test_link_shortener.py @@ -13,36 +13,50 @@ class TestLinkShortenerBot(BotTestCase, DefaultTests): def test_bot_responds_to_empty_message(self) -> None: with patch('requests.get'): - self._test('', - ('No links found. ' - 'Mention the link shortener bot in a conversation and ' - 'then enter any URLs you want to shorten in the body of ' - 'the message.')) + self._test( + '', + ( + 'No links found. ' + 'Mention the link shortener bot in a conversation and ' + 'then enter any URLs you want to shorten in the body of ' + 'the message.' + ), + ) def test_normal(self) -> None: with self.mock_http_conversation('test_normal'): - self._test('Shorten https://www.github.com/zulip/zulip please.', - 'https://www.github.com/zulip/zulip: http://bit.ly/2Ht2hOI') + self._test( + 'Shorten https://www.github.com/zulip/zulip please.', + 'https://www.github.com/zulip/zulip: http://bit.ly/2Ht2hOI', + ) def test_no_links(self) -> None: # No `mock_http_conversation` is necessary because the bot will # recognize that no links are in the message and won't make any HTTP # requests. with patch('requests.get'): - self._test('Shorten nothing please.', - ('No links found. ' - 'Mention the link shortener bot in a conversation and ' - 'then enter any URLs you want to shorten in the body of ' - 'the message.')) + self._test( + 'Shorten nothing please.', + ( + 'No links found. ' + 'Mention the link shortener bot in a conversation and ' + 'then enter any URLs you want to shorten in the body of ' + 'the message.' + ), + ) def test_help(self) -> None: # No `mock_http_conversation` is necessary because the bot will # recognize that the message is 'help' and won't make any HTTP # requests. with patch('requests.get'): - self._test('help', - ('Mention the link shortener bot in a conversation and then ' - 'enter any URLs you want to shorten in the body of the message.')) + self._test( + 'help', + ( + 'Mention the link shortener bot in a conversation and then ' + 'enter any URLs you want to shorten in the body of the message.' + ), + ) def test_exception_when_api_key_is_invalid(self) -> None: bot_test_instance = LinkShortenerHandler() diff --git a/zulip_bots/zulip_bots/bots/mention/mention.py b/zulip_bots/zulip_bots/bots/mention/mention.py index c7153b8..b676c3f 100644 --- a/zulip_bots/zulip_bots/bots/mention/mention.py +++ b/zulip_bots/zulip_bots/bots/mention/mention.py @@ -20,13 +20,19 @@ class MentionHandler: 'Authorization': 'Bearer ' + self.access_token, 'Accept-Version': '1.15', } - test_query_response = requests.get('https://api.mention.net/api/accounts/me', headers=test_query_header) + test_query_response = requests.get( + 'https://api.mention.net/api/accounts/me', headers=test_query_header + ) try: test_query_data = test_query_response.json() - if test_query_data['error'] == 'invalid_grant' and \ - test_query_data['error_description'] == 'The access token provided is invalid.': - bot_handler.quit('Access Token Invalid. Please see doc.md to find out how to get it.') + if ( + test_query_data['error'] == 'invalid_grant' + and test_query_data['error_description'] == 'The access token provided is invalid.' + ): + bot_handler.quit( + 'Access Token Invalid. Please see doc.md to find out how to get it.' + ) except KeyError: pass @@ -71,18 +77,15 @@ class MentionHandler: create_alert_data = { 'name': keyword, - 'query': { - 'type': 'basic', - 'included_keywords': [keyword] - }, + 'query': {'type': 'basic', 'included_keywords': [keyword]}, 'languages': ['en'], - 'sources': ['web'] + 'sources': ['web'], } # type: Any response = requests.post( - 'https://api.mention.net/api/accounts/' + self.account_id - + '/alerts', - data=create_alert_data, headers=create_alert_header, + 'https://api.mention.net/api/accounts/' + self.account_id + '/alerts', + data=create_alert_data, + headers=create_alert_header, ) data_json = response.json() alert_id = data_json['alert']['id'] @@ -94,8 +97,11 @@ class MentionHandler: 'Accept-Version': '1.15', } response = requests.get( - 'https://api.mention.net/api/accounts/' + self.account_id - + '/alerts/' + alert_id + '/mentions', + 'https://api.mention.net/api/accounts/' + + self.account_id + + '/alerts/' + + alert_id + + '/mentions', headers=get_mentions_header, ) data_json = response.json() @@ -123,7 +129,9 @@ class MentionHandler: reply += "[{title}]({id})\n".format(title=mention['title'], id=mention['original_url']) return reply + handler_class = MentionHandler + class MentionNoResponseException(Exception): pass diff --git a/zulip_bots/zulip_bots/bots/mention/test_mention.py b/zulip_bots/zulip_bots/bots/mention/test_mention.py index 02da176..4b4280d 100644 --- a/zulip_bots/zulip_bots/bots/mention/test_mention.py +++ b/zulip_bots/zulip_bots/bots/mention/test_mention.py @@ -8,18 +8,19 @@ class TestMentionBot(BotTestCase, DefaultTests): bot_name = "mention" def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info({'access_token': '12345'}), \ - patch('requests.get'): + with self.mock_config_info({'access_token': '12345'}), patch('requests.get'): self.verify_reply('', 'Empty Mention Query') def test_help_query(self) -> None: - with self.mock_config_info({'access_token': '12345'}), \ - patch('requests.get'): - self.verify_reply('help', ''' + with self.mock_config_info({'access_token': '12345'}), patch('requests.get'): + self.verify_reply( + 'help', + ''' This is a Mention API Bot which will find mentions of the given keyword throughout the web. Version 1.00 - ''') + ''', + ) def test_get_account_id(self) -> None: bot_test_instance = MentionHandler() diff --git a/zulip_bots/zulip_bots/bots/merels/libraries/constants.py b/zulip_bots/zulip_bots/bots/merels/libraries/constants.py index 3936bcf..c567b86 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/constants.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/constants.py @@ -3,45 +3,105 @@ # Do NOT scramble these. This is written such that it starts from top left # to bottom right. -ALLOWED_MOVES = ([0, 0], [0, 3], [0, 6], - [1, 1], [1, 3], [1, 5], - [2, 2], [2, 3], [2, 4], - [3, 0], [3, 1], [3, 2], [3, 4], [3, 5], [3, 6], - [4, 2], [4, 3], [4, 4], - [5, 1], [5, 3], [5, 5], - [6, 0], [6, 3], [6, 6]) +ALLOWED_MOVES = ( + [0, 0], + [0, 3], + [0, 6], + [1, 1], + [1, 3], + [1, 5], + [2, 2], + [2, 3], + [2, 4], + [3, 0], + [3, 1], + [3, 2], + [3, 4], + [3, 5], + [3, 6], + [4, 2], + [4, 3], + [4, 4], + [5, 1], + [5, 3], + [5, 5], + [6, 0], + [6, 3], + [6, 6], +) AM = ALLOWED_MOVES # Do NOT scramble these, This is written such that it starts from horizontal # to vertical, top to bottom, left to right. -HILLS = ([AM[0], AM[1], AM[2]], - [AM[3], AM[4], AM[5]], - [AM[6], AM[7], AM[8]], - [AM[9], AM[10], AM[11]], - [AM[12], AM[13], AM[14]], - [AM[15], AM[16], AM[17]], - [AM[18], AM[19], AM[20]], - [AM[21], AM[22], AM[23]], - [AM[0], AM[9], AM[21]], - [AM[3], AM[10], AM[18]], - [AM[6], AM[11], AM[15]], - [AM[1], AM[4], AM[7]], - [AM[16], AM[19], AM[22]], - [AM[8], AM[12], AM[17]], - [AM[5], AM[13], AM[20]], - [AM[2], AM[14], AM[23]], - ) +HILLS = ( + [AM[0], AM[1], AM[2]], + [AM[3], AM[4], AM[5]], + [AM[6], AM[7], AM[8]], + [AM[9], AM[10], AM[11]], + [AM[12], AM[13], AM[14]], + [AM[15], AM[16], AM[17]], + [AM[18], AM[19], AM[20]], + [AM[21], AM[22], AM[23]], + [AM[0], AM[9], AM[21]], + [AM[3], AM[10], AM[18]], + [AM[6], AM[11], AM[15]], + [AM[1], AM[4], AM[7]], + [AM[16], AM[19], AM[22]], + [AM[8], AM[12], AM[17]], + [AM[5], AM[13], AM[20]], + [AM[2], AM[14], AM[23]], +) -OUTER_SQUARE = ([0, 0], [0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], - [1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [6, 0], - [6, 0], [6, 1], [6, 2], [6, 3], [6, 4], [6, 5], [6, 6], - [0, 6], [1, 6], [2, 6], [3, 6], [4, 6], [5, 6]) +OUTER_SQUARE = ( + [0, 0], + [0, 1], + [0, 2], + [0, 3], + [0, 4], + [0, 5], + [0, 6], + [1, 0], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + [6, 0], + [6, 0], + [6, 1], + [6, 2], + [6, 3], + [6, 4], + [6, 5], + [6, 6], + [0, 6], + [1, 6], + [2, 6], + [3, 6], + [4, 6], + [5, 6], +) -MIDDLE_SQUARE = ([1, 1], [1, 2], [1, 3], [1, 4], [1, 5], - [2, 1], [3, 1], [4, 1], [5, 1], - [5, 1], [5, 2], [5, 3], [5, 4], [5, 5], - [1, 5], [2, 5], [3, 5], [4, 5]) +MIDDLE_SQUARE = ( + [1, 1], + [1, 2], + [1, 3], + [1, 4], + [1, 5], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + [5, 1], + [5, 2], + [5, 3], + [5, 4], + [5, 5], + [1, 5], + [2, 5], + [3, 5], + [4, 5], +) INNER_SQUARE = ([2, 2], [2, 3], [2, 4], [3, 2], [3, 4], [4, 2], [4, 3], [4, 4]) diff --git a/zulip_bots/zulip_bots/bots/merels/libraries/database.py b/zulip_bots/zulip_bots/bots/merels/libraries/database.py index bfef128..f0d64d2 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/database.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/database.py @@ -11,7 +11,7 @@ finished yet so any matches that are finished will be removed. import json -class MerelsStorage(): +class MerelsStorage: def __init__(self, topic_name, storage): """Instantiate storage field. @@ -28,9 +28,8 @@ class MerelsStorage(): """ self.storage = storage - def update_game(self, topic_name, turn, x_taken, o_taken, board, hill_uid, - take_mode): - """ Updates the current status of the game to the database. + def update_game(self, topic_name, turn, x_taken, o_taken, board, hill_uid, take_mode): + """Updates the current status of the game to the database. :param topic_name: The name of the topic :param turn: "X" or "O" @@ -45,13 +44,12 @@ class MerelsStorage(): :return: None """ - parameters = ( - turn, x_taken, o_taken, board, hill_uid, take_mode) + parameters = (turn, x_taken, o_taken, board, hill_uid, take_mode) self.storage.put(topic_name, json.dumps(parameters)) def remove_game(self, topic_name): - """ Removes the game from the database by setting it to an empty + """Removes the game from the database by setting it to an empty string. An empty string marks an empty match. :param topic_name: The name of the topic @@ -75,7 +73,6 @@ class MerelsStorage(): if select == "": return None else: - res = (topic_name, select[0], select[1], select[2], select[3], - select[4], select[5]) + res = (topic_name, select[0], select[1], select[2], select[3], select[4], select[5]) return res diff --git a/zulip_bots/zulip_bots/bots/merels/libraries/game.py b/zulip_bots/zulip_bots/bots/merels/libraries/game.py index c146295..8b6f84e 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/game.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/game.py @@ -11,19 +11,19 @@ from zulip_bots.game_handler import BadMoveException from . import database, mechanics -COMMAND_PATTERN = re.compile( - "^(\\w*).*(\\d,\\d).*(\\d,\\d)|^(\\w+).*(\\d,\\d)") +COMMAND_PATTERN = re.compile("^(\\w*).*(\\d,\\d).*(\\d,\\d)|^(\\w+).*(\\d,\\d)") + def getInfo(): - """ Gets the info on starting the game + """Gets the info on starting the game :return: Info on how to start the game """ - return "To start a game, mention me and add `create`. A game will start " \ - "in that topic. " + return "To start a game, mention me and add `create`. A game will start " "in that topic. " + def getHelp(): - """ Gets the help message + """Gets the help message :return: Help message """ @@ -36,6 +36,7 @@ take (v,h): Take an opponent's man from the grid in phase 2/3 v: vertical position of grid h: horizontal position of grid""" + def unknown_command(): """Returns an unknown command info @@ -44,8 +45,9 @@ def unknown_command(): message = "Unknown command. Available commands: put (v,h), take (v,h), move (v,h) -> (v,h)" raise BadMoveException(message) + def beat(message, topic_name, merels_storage): - """ This gets triggered every time a user send a message in any topic + """This gets triggered every time a user send a message in any topic :param message: User's message :param topic_name: User's current topic :param merels_storage: Merels' storage @@ -59,8 +61,7 @@ def beat(message, topic_name, merels_storage): if match is None: return unknown_command() - if match.group(1) is not None and match.group( - 2) is not None and match.group(3) is not None: + if match.group(1) is not None and match.group(2) is not None and match.group(3) is not None: responses = "" command = match.group(1) @@ -72,11 +73,9 @@ def beat(message, topic_name, merels_storage): if mechanics.get_take_status(topic_name, merels_storage) == 1: - raise BadMoveException("Take is required to proceed." - " Please try again.\n") + raise BadMoveException("Take is required to proceed." " Please try again.\n") - responses += mechanics.move_man(topic_name, p1, p2, - merels_storage) + "\n" + responses += mechanics.move_man(topic_name, p1, p2, merels_storage) + "\n" no_moves = after_event_checkup(responses, topic_name, merels_storage) mechanics.update_hill_uid(topic_name, merels_storage) @@ -102,10 +101,8 @@ def beat(message, topic_name, merels_storage): responses = "" if mechanics.get_take_status(topic_name, merels_storage) == 1: - raise BadMoveException("Take is required to proceed." - " Please try again.\n") - responses += mechanics.put_man(topic_name, p1[0], p1[1], - merels_storage) + "\n" + raise BadMoveException("Take is required to proceed." " Please try again.\n") + responses += mechanics.put_man(topic_name, p1[0], p1[1], merels_storage) + "\n" no_moves = after_event_checkup(responses, topic_name, merels_storage) mechanics.update_hill_uid(topic_name, merels_storage) @@ -121,8 +118,7 @@ def beat(message, topic_name, merels_storage): elif command == "take": responses = "" if mechanics.get_take_status(topic_name, merels_storage) == 1: - responses += mechanics.take_man(topic_name, p1[0], p1[1], - merels_storage) + "\n" + responses += mechanics.take_man(topic_name, p1[0], p1[1], merels_storage) + "\n" if "Failed" in responses: raise BadMoveException(responses) mechanics.update_toggle_take_mode(topic_name, merels_storage) @@ -141,6 +137,7 @@ def beat(message, topic_name, merels_storage): else: return unknown_command() + def check_take_mode(response, topic_name, merels_storage): """This checks whether the previous action can result in a take mode for current player. This assumes that the previous action is successful and not @@ -157,6 +154,7 @@ def check_take_mode(response, topic_name, merels_storage): else: mechanics.update_change_turn(topic_name, merels_storage) + def check_any_moves(topic_name, merels_storage): """Check whether the player can make any moves, if can't switch to another player @@ -167,11 +165,11 @@ def check_any_moves(topic_name, merels_storage): """ if not mechanics.can_make_any_move(topic_name, merels_storage): mechanics.update_change_turn(topic_name, merels_storage) - return "Cannot make any move on the grid. Switching to " \ - "previous player.\n" + return "Cannot make any move on the grid. Switching to " "previous player.\n" return "" + def after_event_checkup(response, topic_name, merels_storage): """After doing certain moves in the game, it will check for take mode availability and check for any possible moves @@ -185,6 +183,7 @@ def after_event_checkup(response, topic_name, merels_storage): check_take_mode(response, topic_name, merels_storage) return check_any_moves(topic_name, merels_storage) + def check_win(topic_name, merels_storage): """Checks whether the current grid has a winner, if it does, finish the game and remove it from the database diff --git a/zulip_bots/zulip_bots/bots/merels/libraries/game_data.py b/zulip_bots/zulip_bots/bots/merels/libraries/game_data.py index 4a1dbe9..d33ef5e 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/game_data.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/game_data.py @@ -9,9 +9,8 @@ from . import mechanics from .interface import construct_grid -class GameData(): - def __init__(self, game_data=( - 'merels', 'X', 0, 0, 'NNNNNNNNNNNNNNNNNNNNNNNN', '', 0)): +class GameData: + def __init__(self, game_data=('merels', 'X', 0, 0, 'NNNNNNNNNNNNNNNNNNNNNNNN', '', 0)): self.topic_name = game_data[0] self.turn = game_data[1] self.x_taken = game_data[2] @@ -30,8 +29,14 @@ class GameData(): """ res = ( - self.topic_name, self.turn, self.x_taken, self.o_taken, self.board, - self.hill_uid, self.take_mode) + self.topic_name, + self.turn, + self.x_taken, + self.o_taken, + self.board, + self.hill_uid, + self.take_mode, + ) return res def grid(self): @@ -63,9 +68,12 @@ class GameData(): :return: A phase number (1, 2, or 3) """ - return mechanics.get_phase_number(self.grid(), self.turn, - self.get_x_piece_possessed_not_on_grid(), - self.get_o_piece_possessed_not_on_grid()) + return mechanics.get_phase_number( + self.grid(), + self.turn, + self.get_x_piece_possessed_not_on_grid(), + self.get_o_piece_possessed_not_on_grid(), + ) def switch_turn(self): """Switches turn between X and O diff --git a/zulip_bots/zulip_bots/bots/merels/libraries/interface.py b/zulip_bots/zulip_bots/bots/merels/libraries/interface.py index 55a031e..66a51bd 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/interface.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/interface.py @@ -56,13 +56,31 @@ def graph_grid(grid): 5 | [{}]---------[{}]---------[{}] | | | | 6 [{}]---------------[{}]---------------[{}]`'''.format( - grid[0][0], grid[0][3], grid[0][6], - grid[1][1], grid[1][3], grid[1][5], - grid[2][2], grid[2][3], grid[2][4], - grid[3][0], grid[3][1], grid[3][2], grid[3][4], grid[3][5], grid[3][6], - grid[4][2], grid[4][3], grid[4][4], - grid[5][1], grid[5][3], grid[5][5], - grid[6][0], grid[6][3], grid[6][6]) + grid[0][0], + grid[0][3], + grid[0][6], + grid[1][1], + grid[1][3], + grid[1][5], + grid[2][2], + grid[2][3], + grid[2][4], + grid[3][0], + grid[3][1], + grid[3][2], + grid[3][4], + grid[3][5], + grid[3][6], + grid[4][2], + grid[4][3], + grid[4][4], + grid[5][1], + grid[5][3], + grid[5][5], + grid[6][0], + grid[6][3], + grid[6][6], + ) def construct_grid(board): @@ -78,8 +96,7 @@ def construct_grid(board): for k, cell in enumerate(board): if cell == "O" or cell == "X": - grid[constants.ALLOWED_MOVES[k][0]][ - constants.ALLOWED_MOVES[k][1]] = cell + grid[constants.ALLOWED_MOVES[k][0]][constants.ALLOWED_MOVES[k][1]] = cell return grid diff --git a/zulip_bots/zulip_bots/bots/merels/libraries/mechanics.py b/zulip_bots/zulip_bots/bots/merels/libraries/mechanics.py index 6322d9f..4621ad1 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/mechanics.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/mechanics.py @@ -52,8 +52,7 @@ def is_jump(vpos_before, hpos_before, vpos_after, hpos_after): False, if it is not jumping """ - distance = sqrt( - (vpos_after - vpos_before) ** 2 + (hpos_after - hpos_before) ** 2) + distance = sqrt((vpos_after - vpos_before) ** 2 + (hpos_after - hpos_before) ** 2) # If the man is in outer square, the distance must be 3 or 1 if [vpos_before, hpos_before] in constants.OUTER_SQUARE: @@ -83,9 +82,9 @@ def get_hills_numbers(grid): v1, h1 = hill[0][0], hill[0][1] v2, h2 = hill[1][0], hill[1][1] v3, h3 = hill[2][0], hill[2][1] - if all(x == "O" for x in - (grid[v1][h1], grid[v2][h2], grid[v3][h3])) or all( - x == "X" for x in (grid[v1][h1], grid[v2][h2], grid[v3][h3])): + if all(x == "O" for x in (grid[v1][h1], grid[v2][h2], grid[v3][h3])) or all( + x == "X" for x in (grid[v1][h1], grid[v2][h2], grid[v3][h3]) + ): relative_hills += str(k) return relative_hills @@ -148,14 +147,14 @@ def is_legal_move(v1, h1, v2, h2, turn, phase, grid): return False # Place all the pieces first before moving one if phase == 3 and get_piece(turn, grid) == 3: - return is_in_grid(v2, h2) and is_empty(v2, h2, grid) and is_own_piece( - v1, h1, turn, grid) + return is_in_grid(v2, h2) and is_empty(v2, h2, grid) and is_own_piece(v1, h1, turn, grid) - return is_in_grid(v2, h2) and is_empty(v2, h2, grid) and ( - not is_jump(v1, h1, v2, h2)) and is_own_piece(v1, - h1, - turn, - grid) + return ( + is_in_grid(v2, h2) + and is_empty(v2, h2, grid) + and (not is_jump(v1, h1, v2, h2)) + and is_own_piece(v1, h1, turn, grid) + ) def is_own_piece(v, h, turn, grid): @@ -195,8 +194,12 @@ def is_legal_take(v, h, turn, grid, take_mode): :return: True if it is legal, False if it is not legal """ - return is_in_grid(v, h) and not is_empty(v, h, grid) and not is_own_piece( - v, h, turn, grid) and take_mode == 1 + return ( + is_in_grid(v, h) + and not is_empty(v, h, grid) + and not is_own_piece(v, h, turn, grid) + and take_mode == 1 + ) def get_piece(turn, grid): @@ -239,8 +242,7 @@ def who_won(topic_name, merels_storage): return "None" -def get_phase_number(grid, turn, x_pieces_possessed_not_on_grid, - o_pieces_possessed_not_on_grid): +def get_phase_number(grid, turn, x_pieces_possessed_not_on_grid, o_pieces_possessed_not_on_grid): """Updates current game phase :param grid: A 2-dimensional 7x7 list @@ -253,8 +255,7 @@ def get_phase_number(grid, turn, x_pieces_possessed_not_on_grid, is "flying" """ - if x_pieces_possessed_not_on_grid != 0 or o_pieces_possessed_not_on_grid \ - != 0: + if x_pieces_possessed_not_on_grid != 0 or o_pieces_possessed_not_on_grid != 0: # Placing pieces return 1 else: @@ -277,14 +278,15 @@ def create_room(topic_name, merels_storage): if merels.create_new_game(topic_name): response = "" - response += "A room has been created in {0}. Starting game now.\n". \ - format(topic_name) + response += "A room has been created in {0}. Starting game now.\n".format(topic_name) response += display_game(topic_name, merels_storage) return response else: - return "Failed: Cannot create an already existing game in {}. " \ - "Please finish the game first.".format(topic_name) + return ( + "Failed: Cannot create an already existing game in {}. " + "Please finish the game first.".format(topic_name) + ) def display_game(topic_name, merels_storage): @@ -309,7 +311,9 @@ def display_game(topic_name, merels_storage): response += interface.graph_grid(data.grid()) + "\n" response += """Phase {}. Take mode: {}. X taken: {}, O taken: {}. - """.format(data.get_phase(), take, data.x_taken, data.o_taken) + """.format( + data.get_phase(), take, data.x_taken, data.o_taken + ) return response @@ -324,8 +328,7 @@ def reset_game(topic_name, merels_storage): merels = database.MerelsStorage(topic_name, merels_storage) merels.remove_game(topic_name) - return "Game removed.\n" + create_room(topic_name, - merels_storage) + "Game reset.\n" + return "Game removed.\n" + create_room(topic_name, merels_storage) + "Game reset.\n" def move_man(topic_name, p1, p2, merels_storage): @@ -344,8 +347,7 @@ def move_man(topic_name, p1, p2, merels_storage): grid = data.grid() # Check legal move - if is_legal_move(p1[0], p1[1], p2[0], p2[1], data.turn, data.get_phase(), - data.grid()): + if is_legal_move(p1[0], p1[1], p2[0], p2[1], data.turn, data.get_phase(), data.grid()): # Move the man move_man_legal(p1[0], p1[1], p2[0], p2[1], grid) # Construct the board back from updated grid @@ -353,14 +355,22 @@ def move_man(topic_name, p1, p2, merels_storage): # Insert/update the current board data.board = board # Update the game data - merels.update_game(data.topic_name, data.turn, data.x_taken, - data.o_taken, data.board, data.hill_uid, - data.take_mode) + merels.update_game( + data.topic_name, + data.turn, + data.x_taken, + data.o_taken, + data.board, + data.hill_uid, + data.take_mode, + ) return "Moved a man from ({}, {}) -> ({}, {}) for {}.".format( - p1[0], p1[1], p2[0], p2[1], data.turn) + p1[0], p1[1], p2[0], p2[1], data.turn + ) else: raise BadMoveException("Failed: That's not a legal move. Please try again.") + def put_man(topic_name, v, h, merels_storage): """Puts a man into the specified cell in topic_name @@ -385,9 +395,15 @@ def put_man(topic_name, v, h, merels_storage): # Insert/update form current board data.board = board # Update the game data - merels.update_game(data.topic_name, data.turn, data.x_taken, - data.o_taken, data.board, data.hill_uid, - data.take_mode) + merels.update_game( + data.topic_name, + data.turn, + data.x_taken, + data.o_taken, + data.board, + data.hill_uid, + data.take_mode, + ) return "Put a man to ({}, {}) for {}.".format(v, h, data.turn) else: raise BadMoveException("Failed: That's not a legal put. Please try again.") @@ -423,9 +439,15 @@ def take_man(topic_name, v, h, merels_storage): # Insert/update form current board data.board = board # Update the game data - merels.update_game(data.topic_name, data.turn, data.x_taken, - data.o_taken, data.board, data.hill_uid, - data.take_mode) + merels.update_game( + data.topic_name, + data.turn, + data.x_taken, + data.o_taken, + data.board, + data.hill_uid, + data.take_mode, + ) return "Taken a man from ({}, {}) for {}.".format(v, h, data.turn) else: raise BadMoveException("Failed: That's not a legal take. Please try again.") @@ -444,9 +466,15 @@ def update_hill_uid(topic_name, merels_storage): data.hill_uid = get_hills_numbers(data.grid()) - merels.update_game(data.topic_name, data.turn, data.x_taken, - data.o_taken, data.board, data.hill_uid, - data.take_mode) + merels.update_game( + data.topic_name, + data.turn, + data.x_taken, + data.o_taken, + data.board, + data.hill_uid, + data.take_mode, + ) def update_change_turn(topic_name, merels_storage): @@ -462,9 +490,15 @@ def update_change_turn(topic_name, merels_storage): data.switch_turn() - merels.update_game(data.topic_name, data.turn, data.x_taken, - data.o_taken, data.board, data.hill_uid, - data.take_mode) + merels.update_game( + data.topic_name, + data.turn, + data.x_taken, + data.o_taken, + data.board, + data.hill_uid, + data.take_mode, + ) def update_toggle_take_mode(topic_name, merels_storage): @@ -480,9 +514,15 @@ def update_toggle_take_mode(topic_name, merels_storage): data.toggle_take_mode() - merels.update_game(data.topic_name, data.turn, data.x_taken, - data.o_taken, data.board, data.hill_uid, - data.take_mode) + merels.update_game( + data.topic_name, + data.turn, + data.x_taken, + data.o_taken, + data.board, + data.hill_uid, + data.take_mode, + ) def get_take_status(topic_name, merels_storage): @@ -534,8 +574,7 @@ def can_take_mode(topic_name, merels_storage): updated_hill_uid = get_hills_numbers(updated_grid) - if current_hill_uid != updated_hill_uid and len(updated_hill_uid) >= len( - current_hill_uid): + if current_hill_uid != updated_hill_uid and len(updated_hill_uid) >= len(current_hill_uid): return True else: return False diff --git a/zulip_bots/zulip_bots/bots/merels/merels.py b/zulip_bots/zulip_bots/bots/merels/merels.py index b27af59..96581ce 100644 --- a/zulip_bots/zulip_bots/bots/merels/merels.py +++ b/zulip_bots/zulip_bots/bots/merels/merels.py @@ -16,8 +16,8 @@ class Storage: def get(self, topic_name): return self.data[topic_name] -class MerelsModel: +class MerelsModel: def __init__(self, board: Any = None) -> None: self.topic = "merels" self.storage = Storage(self.topic) @@ -34,8 +34,9 @@ class MerelsModel: data = game_data.GameData(merels.get_game_data(self.topic)) if data.get_phase() > 1: - if (mechanics.get_piece("X", data.grid()) <= 2) or\ - (mechanics.get_piece("O", data.grid()) <= 2): + if (mechanics.get_piece("X", data.grid()) <= 2) or ( + mechanics.get_piece("O", data.grid()) <= 2 + ): return True return False @@ -43,14 +44,14 @@ class MerelsModel: if self.storage.get(self.topic) == '["X", 0, 0, "NNNNNNNNNNNNNNNNNNNNNNNN", "", 0]': self.storage.put( self.topic, - '["{}", 0, 0, "NNNNNNNNNNNNNNNNNNNNNNNN", "", 0]'.format( - self.token[player_number] - )) + '["{}", 0, 0, "NNNNNNNNNNNNNNNNNNNNNNNN", "", 0]'.format(self.token[player_number]), + ) self.current_board, same_player_move = game.beat(move, self.topic, self.storage) if same_player_move != "": raise SamePlayerMove(same_player_move) return self.current_board + class MerelsMessageHandler: tokens = [':o_button:', ':cross_mark_button:'] @@ -66,11 +67,13 @@ class MerelsMessageHandler: def game_start_message(self) -> str: return game.getHelp() + class MerelsHandler(GameAdapter): ''' You can play merels! Make sure your message starts with "@mention-bot". ''' + META = { 'name': 'merels', 'description': 'Lets you play merels against any player.', @@ -95,9 +98,10 @@ class MerelsHandler(GameAdapter): model, gameMessageHandler, rules, - max_players = 2, - min_players = 2, - supports_computer=False + max_players=2, + min_players=2, + supports_computer=False, ) + handler_class = MerelsHandler diff --git a/zulip_bots/zulip_bots/bots/merels/test/test_constants.py b/zulip_bots/zulip_bots/bots/merels/test/test_constants.py index 80ab8c7..62a3b13 100644 --- a/zulip_bots/zulip_bots/bots/merels/test/test_constants.py +++ b/zulip_bots/zulip_bots/bots/merels/test/test_constants.py @@ -4,47 +4,83 @@ from libraries import constants class CheckIntegrity(unittest.TestCase): - def test_grid_layout_integrity(self): - grid_layout = ([0, 0], [0, 3], [0, 6], - [1, 1], [1, 3], [1, 5], - [2, 2], [2, 3], [2, 4], - [3, 0], [3, 1], [3, 2], [3, 4], [3, 5], [3, 6], - [4, 2], [4, 3], [4, 4], - [5, 1], [5, 3], [5, 5], - [6, 0], [6, 3], [6, 6]) + grid_layout = ( + [0, 0], + [0, 3], + [0, 6], + [1, 1], + [1, 3], + [1, 5], + [2, 2], + [2, 3], + [2, 4], + [3, 0], + [3, 1], + [3, 2], + [3, 4], + [3, 5], + [3, 6], + [4, 2], + [4, 3], + [4, 4], + [5, 1], + [5, 3], + [5, 5], + [6, 0], + [6, 3], + [6, 6], + ) - self.assertEqual(constants.ALLOWED_MOVES, grid_layout, - "Incorrect grid layout.") + self.assertEqual(constants.ALLOWED_MOVES, grid_layout, "Incorrect grid layout.") def test_relative_hills_integrity(self): - grid_layout = ([0, 0], [0, 3], [0, 6], - [1, 1], [1, 3], [1, 5], - [2, 2], [2, 3], [2, 4], - [3, 0], [3, 1], [3, 2], [3, 4], [3, 5], [3, 6], - [4, 2], [4, 3], [4, 4], - [5, 1], [5, 3], [5, 5], - [6, 0], [6, 3], [6, 6]) + grid_layout = ( + [0, 0], + [0, 3], + [0, 6], + [1, 1], + [1, 3], + [1, 5], + [2, 2], + [2, 3], + [2, 4], + [3, 0], + [3, 1], + [3, 2], + [3, 4], + [3, 5], + [3, 6], + [4, 2], + [4, 3], + [4, 4], + [5, 1], + [5, 3], + [5, 5], + [6, 0], + [6, 3], + [6, 6], + ) AM = grid_layout - relative_hills = ([AM[0], AM[1], AM[2]], - [AM[3], AM[4], AM[5]], - [AM[6], AM[7], AM[8]], - [AM[9], AM[10], AM[11]], - [AM[12], AM[13], AM[14]], - [AM[15], AM[16], AM[17]], - [AM[18], AM[19], AM[20]], - [AM[21], AM[22], AM[23]], - [AM[0], AM[9], AM[21]], - [AM[3], AM[10], AM[18]], - [AM[6], AM[11], AM[15]], - [AM[1], AM[4], AM[7]], - [AM[16], AM[19], AM[22]], - [AM[8], AM[12], AM[17]], - [AM[5], AM[13], AM[20]], - [AM[2], AM[14], AM[23]], - ) + relative_hills = ( + [AM[0], AM[1], AM[2]], + [AM[3], AM[4], AM[5]], + [AM[6], AM[7], AM[8]], + [AM[9], AM[10], AM[11]], + [AM[12], AM[13], AM[14]], + [AM[15], AM[16], AM[17]], + [AM[18], AM[19], AM[20]], + [AM[21], AM[22], AM[23]], + [AM[0], AM[9], AM[21]], + [AM[3], AM[10], AM[18]], + [AM[6], AM[11], AM[15]], + [AM[1], AM[4], AM[7]], + [AM[16], AM[19], AM[22]], + [AM[8], AM[12], AM[17]], + [AM[5], AM[13], AM[20]], + [AM[2], AM[14], AM[23]], + ) - self.assertEqual(constants.HILLS, relative_hills, - "Incorrect relative hills arrangement") + self.assertEqual(constants.HILLS, relative_hills, "Incorrect relative hills arrangement") diff --git a/zulip_bots/zulip_bots/bots/merels/test/test_database.py b/zulip_bots/zulip_bots/bots/merels/test/test_database.py index 45b5f2a..4918ebe 100644 --- a/zulip_bots/zulip_bots/bots/merels/test/test_database.py +++ b/zulip_bots/zulip_bots/bots/merels/test/test_database.py @@ -1,4 +1,3 @@ - from libraries import database, game_data from zulip_bots.simple_lib import SimpleStorage @@ -15,8 +14,7 @@ class DatabaseTest(BotTestCase, DefaultTests): def test_obtain_gamedata(self): self.merels.update_game("topic1", "X", 0, 0, 'NNNNNNNNNNNNNNNNNNNNNNNN', "", 0) res = self.merels.get_game_data("topic1") - self.assertTupleEqual(res, ( - 'topic1', 'X', 0, 0, 'NNNNNNNNNNNNNNNNNNNNNNNN', "", 0)) + self.assertTupleEqual(res, ('topic1', 'X', 0, 0, 'NNNNNNNNNNNNNNNNNNNNNNNN', "", 0)) self.assertEqual(len(res), 7) def test_obtain_nonexisting_gamedata(self): diff --git a/zulip_bots/zulip_bots/bots/merels/test/test_interface.py b/zulip_bots/zulip_bots/bots/merels/test/test_interface.py index a8e571d..832c550 100644 --- a/zulip_bots/zulip_bots/bots/merels/test/test_interface.py +++ b/zulip_bots/zulip_bots/bots/merels/test/test_interface.py @@ -4,10 +4,11 @@ from libraries import interface class BoardLayoutTest(unittest.TestCase): - def test_empty_layout_arrangement(self): grid = interface.construct_grid("NNNNNNNNNNNNNNNNNNNNNNNN") - self.assertEqual(interface.graph_grid(grid), '''` 0 1 2 3 4 5 6 + self.assertEqual( + interface.graph_grid(grid), + '''` 0 1 2 3 4 5 6 0 [ ]---------------[ ]---------------[ ] | | | 1 | [ ]---------[ ]---------[ ] | @@ -20,11 +21,14 @@ class BoardLayoutTest(unittest.TestCase): | | | | | 5 | [ ]---------[ ]---------[ ] | | | | - 6 [ ]---------------[ ]---------------[ ]`''') + 6 [ ]---------------[ ]---------------[ ]`''', + ) def test_full_layout_arragement(self): grid = interface.construct_grid("NXONXONXONXONXONXONXONXO") - self.assertEqual(interface.graph_grid(grid), '''` 0 1 2 3 4 5 6 + self.assertEqual( + interface.graph_grid(grid), + '''` 0 1 2 3 4 5 6 0 [ ]---------------[X]---------------[O] | | | 1 | [ ]---------[X]---------[O] | @@ -37,11 +41,14 @@ class BoardLayoutTest(unittest.TestCase): | | | | | 5 | [ ]---------[X]---------[O] | | | | - 6 [ ]---------------[X]---------------[O]`''') + 6 [ ]---------------[X]---------------[O]`''', + ) def test_illegal_character_arrangement(self): grid = interface.construct_grid("ABCDABCDABCDABCDABCDXXOO") - self.assertEqual(interface.graph_grid(grid), '''` 0 1 2 3 4 5 6 + self.assertEqual( + interface.graph_grid(grid), + '''` 0 1 2 3 4 5 6 0 [ ]---------------[ ]---------------[ ] | | | 1 | [ ]---------[ ]---------[ ] | @@ -54,25 +61,27 @@ class BoardLayoutTest(unittest.TestCase): | | | | | 5 | [ ]---------[ ]---------[X] | | | | - 6 [X]---------------[O]---------------[O]`''') + 6 [X]---------------[O]---------------[O]`''', + ) class ParsingTest(unittest.TestCase): - def test_consistent_parse(self): - boards = ["NNNNOOOOXXXXNNNNOOOOXXXX", - "NOXNXOXNOXNOXOXOXNOXONON", - "OOONXNOXNONXONOXNXNNONOX", - "NNNNNNNNNNNNNNNNNNNNNNNN", - "OOOOOOOOOOOOOOOOOOOOOOOO", - "XXXXXXXXXXXXXXXXXXXXXXXX"] + boards = [ + "NNNNOOOOXXXXNNNNOOOOXXXX", + "NOXNXOXNOXNOXOXOXNOXONON", + "OOONXNOXNONXONOXNXNNONOX", + "NNNNNNNNNNNNNNNNNNNNNNNN", + "OOOOOOOOOOOOOOOOOOOOOOOO", + "XXXXXXXXXXXXXXXXXXXXXXXX", + ] for board in boards: - self.assertEqual(board, interface.construct_board( - interface.construct_grid( - interface.construct_board( - interface.construct_grid(board) + self.assertEqual( + board, + interface.construct_board( + interface.construct_grid( + interface.construct_board(interface.construct_grid(board)) ) - ) - ) + ), ) diff --git a/zulip_bots/zulip_bots/bots/merels/test/test_mechanics.py b/zulip_bots/zulip_bots/bots/merels/test/test_mechanics.py index 7aa2385..e8ab014 100644 --- a/zulip_bots/zulip_bots/bots/merels/test/test_mechanics.py +++ b/zulip_bots/zulip_bots/bots/merels/test/test_mechanics.py @@ -6,118 +6,198 @@ from zulip_bots.simple_lib import SimpleStorage class GridTest(unittest.TestCase): - def test_out_of_grid(self): points = [[v, h] for h in range(7) for v in range(7)] - expected_outcomes = [True, False, False, True, False, False, True, - False, True, False, True, False, True, False, - False, False, True, True, True, False, False, - True, True, True, False, True, True, True, - False, False, True, True, True, False, False, - False, True, False, True, False, True, False, - True, False, False, True, False, False, True] + expected_outcomes = [ + True, + False, + False, + True, + False, + False, + True, + False, + True, + False, + True, + False, + True, + False, + False, + False, + True, + True, + True, + False, + False, + True, + True, + True, + False, + True, + True, + True, + False, + False, + True, + True, + True, + False, + False, + False, + True, + False, + True, + False, + True, + False, + True, + False, + False, + True, + False, + False, + True, + ] - test_outcomes = [mechanics.is_in_grid(point[0], point[1]) for point in - points] + test_outcomes = [mechanics.is_in_grid(point[0], point[1]) for point in points] self.assertListEqual(test_outcomes, expected_outcomes) def test_jump_and_grids(self): - points = [[0, 0, 1, 1], [1, 1, 2, 2], [2, 2, 3, 3], [0, 0, 0, 2], - [0, 0, 2, 2], [6, 6, 5, 4]] + points = [ + [0, 0, 1, 1], + [1, 1, 2, 2], + [2, 2, 3, 3], + [0, 0, 0, 2], + [0, 0, 2, 2], + [6, 6, 5, 4], + ] expected_outcomes = [True, True, True, True, True, True] test_outcomes = [ - mechanics.is_jump(point[0], point[1], point[2], point[3]) for point - in points] + mechanics.is_jump(point[0], point[1], point[2], point[3]) for point in points + ] self.assertListEqual(test_outcomes, expected_outcomes) def test_jump_special_cases(self): - points = [[0, 0, 0, 3], [0, 0, 3, 0], [6, 0, 6, 3], [4, 2, 6, 2], - [4, 3, 3, 4], [4, 3, 2, 2], - [0, 0, 0, 6], [0, 0, 1, 1], [0, 0, 2, 2], [3, 0, 3, 1], - [3, 0, 3, 2], [3, 1, 3, 0], [3, 1, 3, 2]] + points = [ + [0, 0, 0, 3], + [0, 0, 3, 0], + [6, 0, 6, 3], + [4, 2, 6, 2], + [4, 3, 3, 4], + [4, 3, 2, 2], + [0, 0, 0, 6], + [0, 0, 1, 1], + [0, 0, 2, 2], + [3, 0, 3, 1], + [3, 0, 3, 2], + [3, 1, 3, 0], + [3, 1, 3, 2], + ] - expected_outcomes = [False, False, False, True, True, True, True, True, - True, False, True, False, False] + expected_outcomes = [ + False, + False, + False, + True, + True, + True, + True, + True, + True, + False, + True, + False, + False, + ] test_outcomes = [ - mechanics.is_jump(point[0], point[1], point[2], point[3]) for point - in points] + mechanics.is_jump(point[0], point[1], point[2], point[3]) for point in points + ] self.assertListEqual(test_outcomes, expected_outcomes) def test_not_populated_move(self): grid = interface.construct_grid("XXXNNNOOOXXXNNNOOOXXXNNN") - moves = [[0, 0, 1, 1], [0, 3, 1, 3], [5, 1, 5, 3], [0, 0, 0, 3], - [0, 0, 3, 0]] + moves = [[0, 0, 1, 1], [0, 3, 1, 3], [5, 1, 5, 3], [0, 0, 0, 3], [0, 0, 3, 0]] expected_outcomes = [True, True, False, False, False] - test_outcomes = [mechanics.is_empty(move[2], move[3], grid) for move in - moves] + test_outcomes = [mechanics.is_empty(move[2], move[3], grid) for move in moves] self.assertListEqual(test_outcomes, expected_outcomes) def test_legal_move(self): grid = interface.construct_grid("XXXNNNOOONNNNNNOOONNNNNN") - presets = [[0, 0, 0, 3, "X", 1], [0, 0, 0, 6, "X", 2], - [0, 0, 3, 6, "X", 3], [0, 0, 2, 2, "X", 3]] + presets = [ + [0, 0, 0, 3, "X", 1], + [0, 0, 0, 6, "X", 2], + [0, 0, 3, 6, "X", 3], + [0, 0, 2, 2, "X", 3], + ] expected_outcomes = [False, False, True, False] test_outcomes = [ - mechanics.is_legal_move(preset[0], preset[1], preset[2], preset[3], - preset[4], preset[5], grid) - for preset in presets] + mechanics.is_legal_move( + preset[0], preset[1], preset[2], preset[3], preset[4], preset[5], grid + ) + for preset in presets + ] self.assertListEqual(test_outcomes, expected_outcomes) def test_legal_put(self): grid = interface.construct_grid("XXXNNNOOOXXXNNNOOOXXXNNN") - presets = [[0, 0, 1], [0, 3, 2], [0, 6, 3], [1, 1, 2], [1, 3, 1], - [1, 6, 1], [1, 5, 1]] + presets = [[0, 0, 1], [0, 3, 2], [0, 6, 3], [1, 1, 2], [1, 3, 1], [1, 6, 1], [1, 5, 1]] expected_outcomes = [False, False, False, False, True, False, True] test_outcomes = [ - mechanics.is_legal_put(preset[0], preset[1], grid, preset[2]) for - preset in presets] + mechanics.is_legal_put(preset[0], preset[1], grid, preset[2]) for preset in presets + ] self.assertListEqual(test_outcomes, expected_outcomes) def test_legal_take(self): grid = interface.construct_grid("XXXNNNOOOXXXNNNOOOXXXNNN") - presets = [[0, 0, "X", 1], [0, 1, "X", 1], [0, 0, "O", 1], - [0, 0, "O", 0], [0, 1, "O", 1], [2, 2, "X", 1], - [2, 3, "X", 1], [2, 4, "O", 1]] + presets = [ + [0, 0, "X", 1], + [0, 1, "X", 1], + [0, 0, "O", 1], + [0, 0, "O", 0], + [0, 1, "O", 1], + [2, 2, "X", 1], + [2, 3, "X", 1], + [2, 4, "O", 1], + ] - expected_outcomes = [False, False, True, False, False, True, True, - False] + expected_outcomes = [False, False, True, False, False, True, True, False] test_outcomes = [ - mechanics.is_legal_take(preset[0], preset[1], preset[2], grid, - preset[3]) for preset in - presets] + mechanics.is_legal_take(preset[0], preset[1], preset[2], grid, preset[3]) + for preset in presets + ] self.assertListEqual(test_outcomes, expected_outcomes) def test_own_piece(self): grid = interface.construct_grid("XXXNNNOOOXXXNNNOOOXXXNNN") - presets = [[0, 0, "X"], [0, 0, "O"], [0, 6, "X"], [0, 6, "O"], - [1, 1, "X"], [1, 1, "O"]] + presets = [[0, 0, "X"], [0, 0, "O"], [0, 6, "X"], [0, 6, "O"], [1, 1, "X"], [1, 1, "O"]] expected_outcomes = [True, False, True, False, False, False] test_outcomes = [ - mechanics.is_own_piece(preset[0], preset[1], preset[2], grid) for - preset in presets] + mechanics.is_own_piece(preset[0], preset[1], preset[2], grid) for preset in presets + ] self.assertListEqual(test_outcomes, expected_outcomes) @@ -172,14 +252,12 @@ class PhaseTest(unittest.TestCase): res = game_data.GameData(merels.get_game_data("test")) self.assertEqual(res.get_phase(), 1) - merels.update_game(res.topic_name, "O", 5, 4, - "XXXXNNNOOOOONNNNNNNNNNNN", "03", 0) + merels.update_game(res.topic_name, "O", 5, 4, "XXXXNNNOOOOONNNNNNNNNNNN", "03", 0) res = game_data.GameData(merels.get_game_data("test")) self.assertEqual(res.board, "XXXXNNNOOOOONNNNNNNNNNNN") self.assertEqual(res.get_phase(), 2) - merels.update_game(res.topic_name, "X", 6, 4, - "XXXNNNNOOOOONNNNNNNNNNNN", "03", 0) + merels.update_game(res.topic_name, "X", 6, 4, "XXXNNNNOOOOONNNNNNNNNNNN", "03", 0) res = game_data.GameData(merels.get_game_data("test")) self.assertEqual(res.board, "XXXNNNNOOOOONNNNNNNNNNNN") self.assertEqual(res.get_phase(), 3) diff --git a/zulip_bots/zulip_bots/bots/merels/test_merels.py b/zulip_bots/zulip_bots/bots/merels/test_merels.py index 14faec5..4066798 100644 --- a/zulip_bots/zulip_bots/bots/merels/test_merels.py +++ b/zulip_bots/zulip_bots/bots/merels/test_merels.py @@ -10,9 +10,13 @@ class TestMerelsBot(BotTestCase, DefaultTests): bot_name = 'merels' def test_no_command(self): - message = dict(content='magic', type='stream', sender_email="boo@email.com", sender_full_name="boo") + message = dict( + content='magic', type='stream', sender_email="boo@email.com", sender_full_name="boo" + ) res = self.get_response(message) - self.assertEqual(res['content'], 'You are not in a game at the moment.'' Type `help` for help.') + self.assertEqual( + res['content'], 'You are not in a game at the moment.' ' Type `help` for help.' + ) # FIXME: Add tests for computer moves # FIXME: Add test lib for game_handler @@ -23,7 +27,9 @@ class TestMerelsBot(BotTestCase, DefaultTests): model, message_handler = self._get_game_handlers() self.assertNotEqual(message_handler.get_player_color(0), None) self.assertNotEqual(message_handler.game_start_message(), None) - self.assertEqual(message_handler.alert_move_message('foo', 'moved right'), 'foo :moved right') + self.assertEqual( + message_handler.alert_move_message('foo', 'moved right'), 'foo :moved right' + ) # Test to see if the attributes exist def test_has_attributes(self) -> None: @@ -55,15 +61,17 @@ class TestMerelsBot(BotTestCase, DefaultTests): bot, bot_handler = self._get_handlers() message = { 'sender_email': '{}@example.com'.format(name), - 'sender_full_name': '{}'.format(name)} + 'sender_full_name': '{}'.format(name), + } bot.add_user_to_cache(message) return bot def setup_game(self) -> None: bot = self.add_user_to_cache('foo') self.add_user_to_cache('baz', bot) - instance = GameInstance(bot, False, 'test game', 'abc123', [ - 'foo@example.com', 'baz@example.com'], 'test') + instance = GameInstance( + bot, False, 'test game', 'abc123', ['foo@example.com', 'baz@example.com'], 'test' + ) bot.instances.update({'abc123': instance}) instance.start() return bot @@ -77,7 +85,9 @@ class TestMerelsBot(BotTestCase, DefaultTests): response = message_handler.parse_board(board) self.assertEqual(response, expected_response) - def _test_determine_game_over(self, board: List[List[int]], players: List[str], expected_response: str) -> None: + def _test_determine_game_over( + self, board: List[List[int]], players: List[str], expected_response: str + ) -> None: model, message_handler = self._get_game_handlers() response = model.determine_game_over(players) self.assertEqual(response, expected_response) diff --git a/zulip_bots/zulip_bots/bots/monkeytestit/lib/extract.py b/zulip_bots/zulip_bots/bots/monkeytestit/lib/extract.py index 90b795d..8753ec3 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/lib/extract.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/lib/extract.py @@ -20,9 +20,11 @@ def fetch(options: dict): res = requests.get("https://monkeytest.it/test", params=options) if "server timed out" in res.text: - return {"error": "The server timed out before sending a response to " - "the request. Report is available at " - "[Test Report History]" - "(https://monkeytest.it/dashboard)."} + return { + "error": "The server timed out before sending a response to " + "the request. Report is available at " + "[Test Report History]" + "(https://monkeytest.it/dashboard)." + } return json.loads(res.text) diff --git a/zulip_bots/zulip_bots/bots/monkeytestit/lib/parse.py b/zulip_bots/zulip_bots/bots/monkeytestit/lib/parse.py index 28e75e3..05c1543 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/lib/parse.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/lib/parse.py @@ -23,12 +23,18 @@ def execute(message: Text, apikey: Text) -> Text: len_params = len(params) if len_params < 2: - return failed("You **must** provide at least an URL to perform a " - "check.") + return failed("You **must** provide at least an URL to perform a " "check.") - options = {"secret": apikey, "url": params[1], "on_load": "true", - "on_click": "true", "page_weight": "true", "seo": "true", - "broken_links": "true", "asset_count": "true"} + options = { + "secret": apikey, + "url": params[1], + "on_load": "true", + "on_click": "true", + "page_weight": "true", + "seo": "true", + "broken_links": "true", + "asset_count": "true", + } # Set the options only if supplied @@ -48,9 +54,11 @@ def execute(message: Text, apikey: Text) -> Text: try: fetch_result = extract.fetch(options) except JSONDecodeError: - return failed("Cannot decode a JSON response. " - "Perhaps faulty link. Link must start " - "with `http://` or `https://`.") + return failed( + "Cannot decode a JSON response. " + "Perhaps faulty link. Link must start " + "with `http://` or `https://`." + ) return report.compose(fetch_result) @@ -58,8 +66,7 @@ def execute(message: Text, apikey: Text) -> Text: # the user needs to modify the asset_count. There are probably ways # to counteract this, but I think this is more fast to run. else: - return "Unknown command. Available commands: `check " \ - "[params]`" + return "Unknown command. Available commands: `check " "[params]`" def failed(message: Text) -> Text: diff --git a/zulip_bots/zulip_bots/bots/monkeytestit/lib/report.py b/zulip_bots/zulip_bots/bots/monkeytestit/lib/report.py index 1c59ebd..7b8c3e6 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/lib/report.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/lib/report.py @@ -74,13 +74,15 @@ def print_failures_checkers(results: Dict) -> Text: :return: A response string containing number of failures in each enabled checkers """ - failures_checkers = [(checker, len(results['failures'][checker])) - for checker in get_enabled_checkers(results) - if checker in results['failures']] # [('seo', 3), ..] + failures_checkers = [ + (checker, len(results['failures'][checker])) + for checker in get_enabled_checkers(results) + if checker in results['failures'] + ] # [('seo', 3), ..] - failures_checkers_messages = ["{} ({})".format(fail_checker[0], - fail_checker[1]) for fail_checker in - failures_checkers] + failures_checkers_messages = [ + "{} ({})".format(fail_checker[0], fail_checker[1]) for fail_checker in failures_checkers + ] failures_checkers_message = ", ".join(failures_checkers_messages) return "Failures from checkers: {}".format(failures_checkers_message) @@ -113,8 +115,7 @@ def print_enabled_checkers(results: Dict) -> Text: :param results: A dictionary containing the results of a check :return: A response string containing enabled checkers """ - return "Enabled checkers: {}".format(", " - .join(get_enabled_checkers(results))) + return "Enabled checkers: {}".format(", ".join(get_enabled_checkers(results))) def print_status(results: Dict) -> Text: diff --git a/zulip_bots/zulip_bots/bots/monkeytestit/monkeytestit.py b/zulip_bots/zulip_bots/bots/monkeytestit/monkeytestit.py index 9555e2b..adcb9cc 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/monkeytestit.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/monkeytestit.py @@ -11,37 +11,43 @@ class MonkeyTestitBot: self.config = None def usage(self): - return "Remember to set your api_key first in the config. After " \ - "that, to perform a check, mention me and add the website.\n\n" \ - "Check doc.md for more options and setup instructions." + return ( + "Remember to set your api_key first in the config. After " + "that, to perform a check, mention me and add the website.\n\n" + "Check doc.md for more options and setup instructions." + ) def initialize(self, bot_handler: BotHandler) -> None: try: self.config = bot_handler.get_config_info('monkeytestit') except NoBotConfigException: - bot_handler.quit("Quitting because there's no config file " - "supplied. See doc.md for a guide on setting up " - "one. If you already know the drill, just create " - "a .conf file with \"monkeytestit\" as the " - "section header and api_key = for " - "the api key.") + bot_handler.quit( + "Quitting because there's no config file " + "supplied. See doc.md for a guide on setting up " + "one. If you already know the drill, just create " + "a .conf file with \"monkeytestit\" as the " + "section header and api_key = for " + "the api key." + ) self.api_key = self.config.get('api_key') if not self.api_key: - bot_handler.quit("Config file exists, but can't find api_key key " - "or value. Perhaps it is misconfigured. Check " - "doc.md for details on how to setup the config.") + bot_handler.quit( + "Config file exists, but can't find api_key key " + "or value. Perhaps it is misconfigured. Check " + "doc.md for details on how to setup the config." + ) logging.info("Checking validity of API key. This will take a while.") - if "wrong secret" in parse.execute("check https://website", - self.api_key).lower(): - bot_handler.quit("API key exists, but it is not valid. Reconfigure" - " your api_key value and try again.") + if "wrong secret" in parse.execute("check https://website", self.api_key).lower(): + bot_handler.quit( + "API key exists, but it is not valid. Reconfigure" + " your api_key value and try again." + ) - def handle_message(self, message: Dict[str, str], - bot_handler: BotHandler) -> None: + def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: content = message['content'] response = parse.execute(content, self.api_key) diff --git a/zulip_bots/zulip_bots/bots/monkeytestit/test_monkeytestit.py b/zulip_bots/zulip_bots/bots/monkeytestit/test_monkeytestit.py index 921373e..07bdce6 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/test_monkeytestit.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/test_monkeytestit.py @@ -9,7 +9,8 @@ class TestMonkeyTestitBot(BotTestCase, DefaultTests): def setUp(self): self.monkeytestit_class = import_module( - "zulip_bots.bots.monkeytestit.monkeytestit").MonkeyTestitBot + "zulip_bots.bots.monkeytestit.monkeytestit" + ).MonkeyTestitBot def test_bot_responds_to_empty_message(self): message = dict( diff --git a/zulip_bots/zulip_bots/bots/salesforce/salesforce.py b/zulip_bots/zulip_bots/bots/salesforce/salesforce.py index 46e4e71..c85b310 100644 --- a/zulip_bots/zulip_bots/bots/salesforce/salesforce.py +++ b/zulip_bots/zulip_bots/bots/salesforce/salesforce.py @@ -32,8 +32,9 @@ def get_help_text() -> str: command_text = '' for command in commands: if 'template' in command.keys() and 'description' in command.keys(): - command_text += '**{}**: {}\n'.format('{} [arguments]'.format( - command['template']), command['description']) + command_text += '**{}**: {}\n'.format( + '{} [arguments]'.format(command['template']), command['description'] + ) object_type_text = '' for object_type in object_types.values(): object_type_text += '{}\n'.format(object_type['table']) @@ -41,11 +42,11 @@ def get_help_text() -> str: def format_result( - result: Dict[str, Any], - exclude_keys: List[str] = [], - force_keys: List[str] = [], - rank_output: bool = False, - show_all_keys: bool = False + result: Dict[str, Any], + exclude_keys: List[str] = [], + force_keys: List[str] = [], + rank_output: bool = False, + show_all_keys: bool = False, ) -> str: exclude_keys += ['Name', 'attributes', 'Id'] output = '' @@ -53,8 +54,7 @@ def format_result( return 'No records found.' if result['totalSize'] == 1: record = result['records'][0] - output += '**[{}]({}{})**\n'.format(record['Name'], - login_url, record['Id']) + output += '**[{}]({}{})**\n'.format(record['Name'], login_url, record['Id']) for key, value in record.items(): if key not in exclude_keys: output += '>**{}**: {}\n'.format(key, value) @@ -62,8 +62,7 @@ def format_result( for i, record in enumerate(result['records']): if rank_output: output += '{}) '.format(i + 1) - output += '**[{}]({}{})**\n'.format(record['Name'], - login_url, record['Id']) + output += '**[{}]({}{})**\n'.format(record['Name'], login_url, record['Id']) added_keys = False for key, value in record.items(): if key in force_keys or (show_all_keys and key not in exclude_keys): @@ -74,7 +73,9 @@ def format_result( return output -def query_salesforce(arg: str, salesforce: simple_salesforce.Salesforce, command: Dict[str, Any]) -> str: +def query_salesforce( + arg: str, salesforce: simple_salesforce.Salesforce, command: Dict[str, Any] +) -> str: arg = arg.strip() qarg = arg.split(' -', 1)[0] split_args = [] # type: List[str] @@ -92,8 +93,9 @@ def query_salesforce(arg: str, salesforce: simple_salesforce.Salesforce, command if 'query' in command.keys(): query = command['query'] object_type = object_types[command['object']] - res = salesforce.query(query.format( - object_type['fields'], object_type['table'], qarg, limit_num)) + res = salesforce.query( + query.format(object_type['fields'], object_type['table'], qarg, limit_num) + ) exclude_keys = [] # type: List[str] if 'exclude_keys' in command.keys(): exclude_keys = command['exclude_keys'] @@ -106,7 +108,13 @@ def query_salesforce(arg: str, salesforce: simple_salesforce.Salesforce, command show_all_keys = 'show' in split_args if 'show_all_keys' in command.keys(): show_all_keys = command['show_all_keys'] or 'show' in split_args - return format_result(res, exclude_keys=exclude_keys, force_keys=force_keys, rank_output=rank_output, show_all_keys=show_all_keys) + return format_result( + res, + exclude_keys=exclude_keys, + force_keys=force_keys, + rank_output=rank_output, + show_all_keys=show_all_keys, + ) def get_salesforce_link_details(link: str, sf: Any) -> str: @@ -116,8 +124,7 @@ def get_salesforce_link_details(link: str, sf: Any) -> str: return 'Invalid salesforce link' id = re_id_res.group().strip('/') for object_type in object_types.values(): - res = sf.query(link_query.format( - object_type['fields'], object_type['table'], id)) + res = sf.query(link_query.format(object_type['fields'], object_type['table'], id)) if res['totalSize'] == 1: return format_result(res) return 'No object found. Make sure it is of the supported types. Type `help` for more info.' @@ -160,7 +167,7 @@ class SalesforceHandler: self.sf = simple_salesforce.Salesforce( username=self.config_info['username'], password=self.config_info['password'], - security_token=self.config_info['security_token'] + security_token=self.config_info['security_token'], ) except simple_salesforce.exceptions.SalesforceAuthenticationFailed as err: bot_handler.quit('Failed to log in to Salesforce. {} {}'.format(err.code, err.message)) diff --git a/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py b/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py index 790ec2e..21be0c9 100644 --- a/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py +++ b/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py @@ -26,7 +26,7 @@ def mock_salesforce_auth(is_success: bool) -> Iterator[None]: else: with patch( 'simple_salesforce.api.Salesforce.__init__', - side_effect=SalesforceAuthenticationFailed(403, 'auth failed') + side_effect=SalesforceAuthenticationFailed(403, 'auth failed'), ) as mock_sf_init: mock_sf_init.return_value = None yield @@ -34,16 +34,13 @@ def mock_salesforce_auth(is_success: bool) -> Iterator[None]: @contextmanager def mock_salesforce_commands_types() -> Iterator[None]: - with patch('zulip_bots.bots.salesforce.utils.commands', mock_commands), \ - patch('zulip_bots.bots.salesforce.utils.object_types', mock_object_types): + with patch('zulip_bots.bots.salesforce.utils.commands', mock_commands), patch( + 'zulip_bots.bots.salesforce.utils.object_types', mock_object_types + ): yield -mock_config = { - 'username': 'name@example.com', - 'password': 'foo', - 'security_token': 'abcdefg' -} +mock_config = {'username': 'name@example.com', 'password': 'foo', 'security_token': 'abcdefg'} help_text = '''Salesforce bot This bot can do simple salesforce query requests @@ -86,24 +83,15 @@ mock_commands = [ 'rank_output': True, 'force_keys': ['Amount'], 'exclude_keys': ['Status'], - 'show_all_keys': True + 'show_all_keys': True, }, - { - 'commands': ['echo'], - 'callback': echo - } + {'commands': ['echo'], 'callback': echo}, ] mock_object_types = { - 'contact': { - 'fields': 'Id, Name, Phone', - 'table': 'Table' - }, - 'opportunity': { - 'fields': 'Id, Name, Amount, Status', - 'table': 'Table' - } + 'contact': {'fields': 'Id, Name, Phone', 'table': 'Table'}, + 'opportunity': {'fields': 'Id, Name, Amount, Status', 'table': 'Table'}, } @@ -111,16 +99,15 @@ class TestSalesforceBot(BotTestCase, DefaultTests): bot_name = "salesforce" # type: str def _test(self, test_name: str, message: str, response: str, auth_success: bool = True) -> None: - with self.mock_config_info(mock_config), \ - mock_salesforce_auth(auth_success), \ - mock_salesforce_query(test_name, 'salesforce'), \ - mock_salesforce_commands_types(): + with self.mock_config_info(mock_config), mock_salesforce_auth( + auth_success + ), mock_salesforce_query(test_name, 'salesforce'), mock_salesforce_commands_types(): self.verify_reply(message, response) def _test_initialize(self, auth_success: bool = True) -> None: - with self.mock_config_info(mock_config), \ - mock_salesforce_auth(auth_success), \ - mock_salesforce_commands_types(): + with self.mock_config_info(mock_config), mock_salesforce_auth( + auth_success + ), mock_salesforce_commands_types(): bot, bot_handler = self._get_handlers() def test_bot_responds_to_empty_message(self) -> None: @@ -170,8 +157,7 @@ class TestSalesforceBot(BotTestCase, DefaultTests): def test_help(self) -> None: self._test('test_one_result', 'help', help_text) self._test('test_one_result', 'foo bar baz', help_text) - self._test('test_one_result', 'find contact', - 'Usage: find contact [arguments]') + self._test('test_one_result', 'find contact', 'Usage: find contact [arguments]') def test_bad_auth(self) -> None: with self.assertRaises(StubBotHandler.BotQuitException): @@ -184,15 +170,15 @@ class TestSalesforceBot(BotTestCase, DefaultTests): res = '''**[foo](https://login.salesforce.com/foo_id)** >**Phone**: 020 1234 5678 ''' - self._test('test_one_result', - 'https://login.salesforce.com/1c3e5g7i9k1m3o5q7s', res) + self._test('test_one_result', 'https://login.salesforce.com/1c3e5g7i9k1m3o5q7s', res) def test_link_invalid(self) -> None: - self._test('test_one_result', - 'https://login.salesforce.com/foo/bar/1c3e5g7$i9k1m3o5q7', - 'Invalid salesforce link') + self._test( + 'test_one_result', + 'https://login.salesforce.com/foo/bar/1c3e5g7$i9k1m3o5q7', + 'Invalid salesforce link', + ) def test_link_no_results(self) -> None: res = 'No object found. Make sure it is of the supported types. Type `help` for more info.' - self._test('test_no_results', - 'https://login.salesforce.com/1c3e5g7i9k1m3o5q7s', res) + self._test('test_no_results', 'https://login.salesforce.com/1c3e5g7i9k1m3o5q7s', res) diff --git a/zulip_bots/zulip_bots/bots/salesforce/utils.py b/zulip_bots/zulip_bots/bots/salesforce/utils.py index 2861d1d..457b9cf 100644 --- a/zulip_bots/zulip_bots/bots/salesforce/utils.py +++ b/zulip_bots/zulip_bots/bots/salesforce/utils.py @@ -8,42 +8,49 @@ commands = [ 'commands': ['search account', 'find account', 'search accounts', 'find accounts'], 'object': 'account', 'description': 'Returns a list of accounts of the name specified', - 'template': 'search account ' + 'template': 'search account ', }, { 'commands': ['search contact', 'find contact', 'search contacts', 'find contacts'], 'object': 'contact', 'description': 'Returns a list of contacts of the name specified', - 'template': 'search contact ' + 'template': 'search contact ', }, { - 'commands': ['search opportunity', 'find opportunity', 'search opportunities', 'find opportunities'], + 'commands': [ + 'search opportunity', + 'find opportunity', + 'search opportunities', + 'find opportunities', + ], 'object': 'opportunity', 'description': 'Returns a list of opportunities of the name specified', - 'template': 'search opportunity ' + 'template': 'search opportunity ', }, { - 'commands': ['search top opportunity', 'find top opportunity', 'search top opportunities', 'find top opportunities'], + 'commands': [ + 'search top opportunity', + 'find top opportunity', + 'search top opportunities', + 'find top opportunities', + ], 'object': 'opportunity', 'query': 'SELECT {} FROM {} WHERE isClosed=false ORDER BY amount DESC LIMIT {}', 'description': 'Returns a list of opportunities organised by amount', 'template': 'search top opportunities ', 'rank_output': True, - 'force_keys': ['Amount'] - } + 'force_keys': ['Amount'], + }, ] # type: List[Dict[str, Any]] object_types = { 'account': { 'fields': 'Id, Name, Phone, BillingStreet, BillingCity, BillingState', - 'table': 'Account' - }, - 'contact': { - 'fields': 'Id, Name, Phone, MobilePhone, Email', - 'table': 'Contact' + 'table': 'Account', }, + 'contact': {'fields': 'Id, Name, Phone, MobilePhone, Email', 'table': 'Contact'}, 'opportunity': { 'fields': 'Id, Name, Amount, Probability, StageName, CloseDate', - 'table': 'Opportunity' - } + 'table': 'Opportunity', + }, } # type: Dict[str, Dict[str, str]] diff --git a/zulip_bots/zulip_bots/bots/stack_overflow/stack_overflow.py b/zulip_bots/zulip_bots/bots/stack_overflow/stack_overflow.py index 40f5e99..61c7be4 100644 --- a/zulip_bots/zulip_bots/bots/stack_overflow/stack_overflow.py +++ b/zulip_bots/zulip_bots/bots/stack_overflow/stack_overflow.py @@ -7,6 +7,7 @@ from zulip_bots.lib import BotHandler # See readme.md for instructions on running this code. + class StackOverflowHandler: ''' This plugin facilitates searching Stack Overflow for a @@ -34,7 +35,9 @@ class StackOverflowHandler: bot_response = self.get_bot_stackoverflow_response(message, bot_handler) bot_handler.send_reply(message, bot_response) - def get_bot_stackoverflow_response(self, message: Dict[str, str], bot_handler: BotHandler) -> Optional[str]: + def get_bot_stackoverflow_response( + self, message: Dict[str, str], bot_handler: BotHandler + ) -> Optional[str]: '''This function returns the URLs of the requested topic.''' help_text = 'Please enter your query after @mention-bot to search StackOverflow' @@ -45,36 +48,38 @@ class StackOverflowHandler: return help_text query_stack_url = 'http://api.stackexchange.com/2.2/search/advanced' - query_stack_params = dict( - order='desc', - sort='relevance', - site='stackoverflow', - title=query - ) + query_stack_params = dict(order='desc', sort='relevance', site='stackoverflow', title=query) try: data = requests.get(query_stack_url, params=query_stack_params) except requests.exceptions.RequestException: logging.error('broken link') - return 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' \ - 'Please try again later.' + return ( + 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' + 'Please try again later.' + ) # Checking if the bot accessed the link. if data.status_code != 200: logging.error('Page not found.') - return 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' \ - 'Please try again later.' + return ( + 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' + 'Please try again later.' + ) new_content = 'For search term:' + query + '\n' # Checking if there is content for the searched term if len(data.json()['items']) == 0: - new_content = 'I am sorry. The search term you provided is not found :slightly_frowning_face:' + new_content = ( + 'I am sorry. The search term you provided is not found :slightly_frowning_face:' + ) else: for i in range(min(3, len(data.json()['items']))): search_string = data.json()['items'][i]['title'] link = data.json()['items'][i]['link'] - new_content += str(i+1) + ' : ' + '[' + search_string + ']' + '(' + link + ')\n' + new_content += str(i + 1) + ' : ' + '[' + search_string + ']' + '(' + link + ')\n' return new_content + handler_class = StackOverflowHandler diff --git a/zulip_bots/zulip_bots/bots/stack_overflow/test_stack_overflow.py b/zulip_bots/zulip_bots/bots/stack_overflow/test_stack_overflow.py index 06c8ae7..351491f 100755 --- a/zulip_bots/zulip_bots/bots/stack_overflow/test_stack_overflow.py +++ b/zulip_bots/zulip_bots/bots/stack_overflow/test_stack_overflow.py @@ -9,42 +9,46 @@ class TestStackoverflowBot(BotTestCase, DefaultTests): # Single-word query bot_request = 'restful' - bot_response = ('''For search term:restful + bot_response = '''For search term:restful 1 : [What exactly is RESTful programming?](https://stackoverflow.com/questions/671118/what-exactly-is-restful-programming) 2 : [RESTful Authentication](https://stackoverflow.com/questions/319530/restful-authentication) 3 : [RESTful URL design for search](https://stackoverflow.com/questions/319530/restful-authentication) -''') +''' with self.mock_http_conversation('test_single_word'): self.verify_reply(bot_request, bot_response) # Multi-word query bot_request = 'what is flutter' - bot_response = ('''For search term:what is flutter + bot_response = '''For search term:what is flutter 1 : [What is flutter/dart and what are its benefits over other tools?](https://stackoverflow.com/questions/49023008/what-is-flutter-dart-and-what-are-its-benefits-over-other-tools) -''') +''' with self.mock_http_conversation('test_multi_word'): self.verify_reply(bot_request, bot_response) # Number query bot_request = '113' - bot_response = ('''For search term:113 + bot_response = '''For search term:113 1 : [INSTALL_FAILED_NO_MATCHING_ABIS res-113](https://stackoverflow.com/questions/47117788/install-failed-no-matching-abis-res-113) 2 : [com.sun.tools.xjc.reader.Ring.get(Ring.java:113)](https://stackoverflow.com/questions/12848282/com-sun-tools-xjc-reader-ring-getring-java113) 3 : [no route to host error 113](https://stackoverflow.com/questions/10516222/no-route-to-host-error-113) -''') +''' with self.mock_http_conversation('test_number_query'): self.verify_reply(bot_request, bot_response) # Incorrect word bot_request = 'narendra' - bot_response = "I am sorry. The search term you provided is not found :slightly_frowning_face:" + bot_response = ( + "I am sorry. The search term you provided is not found :slightly_frowning_face:" + ) with self.mock_http_conversation('test_incorrect_query'): self.verify_reply(bot_request, bot_response) # 404 status code bot_request = 'Zulip' - bot_response = 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' \ - 'Please try again later.' + bot_response = ( + 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' + 'Please try again later.' + ) with self.mock_http_conversation('test_status_code'): self.verify_reply(bot_request, bot_response) diff --git a/zulip_bots/zulip_bots/bots/susi/susi.py b/zulip_bots/zulip_bots/bots/susi/susi.py index cd85cbc..31f09db 100644 --- a/zulip_bots/zulip_bots/bots/susi/susi.py +++ b/zulip_bots/zulip_bots/bots/susi/susi.py @@ -50,4 +50,5 @@ class SusiHandler: answer = "I don't understand. Can you rephrase?" bot_handler.send_reply(message, answer) + handler_class = SusiHandler diff --git a/zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py b/zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py index d3e9cae..70f4824 100644 --- a/zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py +++ b/zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py @@ -17,51 +17,49 @@ class TestTicTacToeBot(BotTestCase, DefaultTests): # avoid these errors. def test_get_value(self) -> None: - board = [[0, 1, 0], - [0, 0, 0], - [0, 0, 2]] + board = [[0, 1, 0], [0, 0, 0], [0, 0, 2]] position = (0, 1) response = 1 self._test_get_value(board, position, response) - def _test_get_value(self, board: List[List[int]], position: Tuple[int, int], expected_response: int) -> None: + def _test_get_value( + self, board: List[List[int]], position: Tuple[int, int], expected_response: int + ) -> None: model, message_handler = self._get_game_handlers() tictactoeboard = model(board) response = tictactoeboard.get_value(board, position) self.assertEqual(response, expected_response) def test_determine_game_over_with_win(self) -> None: - board = [[1, 1, 1], - [0, 2, 0], - [2, 0, 2]] + board = [[1, 1, 1], [0, 2, 0], [2, 0, 2]] players = ['Human', 'Computer'] response = 'current turn' self._test_determine_game_over_with_win(board, players, response) - def _test_determine_game_over_with_win(self, board: List[List[int]], players: List[str], expected_response: str) -> None: + def _test_determine_game_over_with_win( + self, board: List[List[int]], players: List[str], expected_response: str + ) -> None: model, message_handler = self._get_game_handlers() tictactoegame = model(board) response = tictactoegame.determine_game_over(players) self.assertEqual(response, expected_response) def test_determine_game_over_with_draw(self) -> None: - board = [[1, 2, 1], - [1, 2, 1], - [2, 1, 2]] + board = [[1, 2, 1], [1, 2, 1], [2, 1, 2]] players = ['Human', 'Computer'] response = 'draw' self._test_determine_game_over_with_draw(board, players, response) - def _test_determine_game_over_with_draw(self, board: List[List[int]], players: List[str], expected_response: str) -> None: + def _test_determine_game_over_with_draw( + self, board: List[List[int]], players: List[str], expected_response: str + ) -> None: model, message_handler = self._get_game_handlers() tictactoeboard = model(board) response = tictactoeboard.determine_game_over(players) self.assertEqual(response, expected_response) def test_board_is_full(self) -> None: - board = [[1, 0, 1], - [1, 2, 1], - [2, 1, 2]] + board = [[1, 0, 1], [1, 2, 1], [2, 1, 2]] response = False self._test_board_is_full(board, response) @@ -72,9 +70,7 @@ class TestTicTacToeBot(BotTestCase, DefaultTests): self.assertEqual(response, expected_response) def test_contains_winning_move(self) -> None: - board = [[1, 1, 1], - [0, 2, 0], - [2, 0, 2]] + board = [[1, 1, 1], [0, 2, 0], [2, 0, 2]] response = True self._test_contains_winning_move(board, response) @@ -85,22 +81,20 @@ class TestTicTacToeBot(BotTestCase, DefaultTests): self.assertEqual(response, expected_response) def test_get_locations_of_char(self) -> None: - board = [[0, 0, 0], - [0, 0, 0], - [0, 0, 1]] + board = [[0, 0, 0], [0, 0, 0], [0, 0, 1]] response = [[2, 2]] self._test_get_locations_of_char(board, response) - def _test_get_locations_of_char(self, board: List[List[int]], expected_response: List[List[int]]) -> None: + def _test_get_locations_of_char( + self, board: List[List[int]], expected_response: List[List[int]] + ) -> None: model, message_handler = self._get_game_handlers() tictactoeboard = model(board) response = tictactoeboard.get_locations_of_char(board, 1) self.assertEqual(response, expected_response) def test_is_valid_move(self) -> None: - board = [[0, 0, 0], - [0, 0, 0], - [1, 0, 2]] + board = [[0, 0, 0], [0, 0, 0], [1, 0, 2]] move = "1,2" response = True self._test_is_valid_move(board, move, response) @@ -109,7 +103,9 @@ class TestTicTacToeBot(BotTestCase, DefaultTests): response = False self._test_is_valid_move(board, move, response) - def _test_is_valid_move(self, board: List[List[int]], move: str, expected_response: bool) -> None: + def _test_is_valid_move( + self, board: List[List[int]], move: str, expected_response: bool + ) -> None: model, message_handler = self._get_game_handlers() tictactoeboard = model(board) response = tictactoeboard.is_valid_move(move) @@ -130,24 +126,20 @@ class TestTicTacToeBot(BotTestCase, DefaultTests): model, message_handler = self._get_game_handlers() self.assertNotEqual(message_handler.get_player_color(0), None) self.assertNotEqual(message_handler.game_start_message(), None) - self.assertEqual(message_handler.alert_move_message( - 'foo', 'move 3'), 'foo put a token at 3') + self.assertEqual( + message_handler.alert_move_message('foo', 'move 3'), 'foo put a token at 3' + ) def test_has_attributes(self) -> None: model, message_handler = self._get_game_handlers() self.assertTrue(hasattr(message_handler, 'parse_board') is not None) - self.assertTrue( - hasattr(message_handler, 'alert_move_message') is not None) + self.assertTrue(hasattr(message_handler, 'alert_move_message') is not None) self.assertTrue(hasattr(model, 'current_board') is not None) self.assertTrue(hasattr(model, 'determine_game_over') is not None) def test_parse_board(self) -> None: - board = [[0, 1, 0], - [0, 0, 0], - [0, 0, 2]] - response = ':one: :x: :three:\n\n' +\ - ':four: :five: :six:\n\n' +\ - ':seven: :eight: :o:\n\n' + board = [[0, 1, 0], [0, 0, 0], [0, 0, 2]] + response = ':one: :x: :three:\n\n' + ':four: :five: :six:\n\n' + ':seven: :eight: :o:\n\n' self._test_parse_board(board, response) def _test_parse_board(self, board: List[List[int]], expected_response: str) -> None: @@ -160,7 +152,7 @@ class TestTicTacToeBot(BotTestCase, DefaultTests): bot, bot_handler = self._get_handlers() message = { 'sender_email': '{}@example.com'.format(name), - 'sender_full_name': '{}'.format(name) + 'sender_full_name': '{}'.format(name), } bot.add_user_to_cache(message) return bot @@ -168,8 +160,9 @@ class TestTicTacToeBot(BotTestCase, DefaultTests): def setup_game(self) -> None: bot = self.add_user_to_cache('foo') self.add_user_to_cache('baz', bot) - instance = GameInstance(bot, False, 'test game', 'abc123', [ - 'foo@example.com', 'baz@example.com'], 'test') + instance = GameInstance( + bot, False, 'test game', 'abc123', ['foo@example.com', 'baz@example.com'], 'test' + ) bot.instances.update({'abc123': instance}) instance.start() return bot diff --git a/zulip_bots/zulip_bots/bots/tictactoe/tictactoe.py b/zulip_bots/zulip_bots/bots/tictactoe/tictactoe.py index 06447c5..053a831 100644 --- a/zulip_bots/zulip_bots/bots/tictactoe/tictactoe.py +++ b/zulip_bots/zulip_bots/bots/tictactoe/tictactoe.py @@ -13,19 +13,18 @@ class TicTacToeModel: smarter = True # If smarter is True, the computer will do some extra thinking - it'll be harder for the user. - triplets = [[(0, 0), (0, 1), (0, 2)], # Row 1 - [(1, 0), (1, 1), (1, 2)], # Row 2 - [(2, 0), (2, 1), (2, 2)], # Row 3 - [(0, 0), (1, 0), (2, 0)], # Column 1 - [(0, 1), (1, 1), (2, 1)], # Column 2 - [(0, 2), (1, 2), (2, 2)], # Column 3 - [(0, 0), (1, 1), (2, 2)], # Diagonal 1 - [(0, 2), (1, 1), (2, 0)] # Diagonal 2 - ] + triplets = [ + [(0, 0), (0, 1), (0, 2)], # Row 1 + [(1, 0), (1, 1), (1, 2)], # Row 2 + [(2, 0), (2, 1), (2, 2)], # Row 3 + [(0, 0), (1, 0), (2, 0)], # Column 1 + [(0, 1), (1, 1), (2, 1)], # Column 2 + [(0, 2), (1, 2), (2, 2)], # Column 3 + [(0, 0), (1, 1), (2, 2)], # Diagonal 1 + [(0, 2), (1, 1), (2, 0)], # Diagonal 2 + ] - initial_board = [[0, 0, 0], - [0, 0, 0], - [0, 0, 0]] + initial_board = [[0, 0, 0], [0, 0, 0], [0, 0, 0]] def __init__(self, board: Any = None) -> None: if board is not None: @@ -44,7 +43,7 @@ class TicTacToeModel: return '' def board_is_full(self, board: Any) -> bool: - ''' Determines if the board is full or not. ''' + '''Determines if the board is full or not.''' for row in board: for element in row: if element == 0: @@ -53,8 +52,8 @@ class TicTacToeModel: # Used for current board & trial computer board def contains_winning_move(self, board: Any) -> bool: - ''' Returns true if all coordinates in a triplet have the same value in them (x or o) and no coordinates - in the triplet are blank. ''' + '''Returns true if all coordinates in a triplet have the same value in them (x or o) and no coordinates + in the triplet are blank.''' for triplet in self.triplets: if ( self.get_value(board, triplet[0]) @@ -66,7 +65,7 @@ class TicTacToeModel: return False def get_locations_of_char(self, board: Any, char: int) -> List[List[int]]: - ''' Gets the locations of the board that have char in them. ''' + '''Gets the locations of the board that have char in them.''' locations = [] for row in range(3): for col in range(3): @@ -75,8 +74,8 @@ class TicTacToeModel: return locations def two_blanks(self, triplet: List[Tuple[int, int]], board: Any) -> List[Tuple[int, int]]: - ''' Determines which rows/columns/diagonals have two blank spaces and an 2 already in them. It's more advantageous - for the computer to move there. This is used when the computer makes its move. ''' + '''Determines which rows/columns/diagonals have two blank spaces and an 2 already in them. It's more advantageous + for the computer to move there. This is used when the computer makes its move.''' o_found = False for position in triplet: @@ -95,9 +94,8 @@ class TicTacToeModel: return [] def computer_move(self, board: Any, player_number: Any) -> Any: - ''' The computer's logic for making its move. ''' - my_board = copy.deepcopy( - board) # First the board is copied; used later on + '''The computer's logic for making its move.''' + my_board = copy.deepcopy(board) # First the board is copied; used later on blank_locations = self.get_locations_of_char(my_board, 0) # Gets the locations that already have x's x_locations = self.get_locations_of_char(board, 1) @@ -186,7 +184,7 @@ class TicTacToeModel: return board def is_valid_move(self, move: str) -> bool: - ''' Checks the validity of the coordinate input passed in to make sure it's not out-of-bounds (ex. 5, 5) ''' + '''Checks the validity of the coordinate input passed in to make sure it's not out-of-bounds (ex. 5, 5)''' try: split_move = move.split(",") row = split_move[0].strip() @@ -220,9 +218,19 @@ class TicTacToeMessageHandler: tokens = [':x:', ':o:'] def parse_row(self, row: Tuple[int, int], row_num: int) -> str: - ''' Takes the row passed in as a list and returns it as a string. ''' + '''Takes the row passed in as a list and returns it as a string.''' row_chars = [] - num_symbols = [':one:', ':two:', ':three:', ':four:', ':five:', ':six:', ':seven:', ':eight:', ':nine:'] + num_symbols = [ + ':one:', + ':two:', + ':three:', + ':four:', + ':five:', + ':six:', + ':seven:', + ':eight:', + ':nine:', + ] for i, e in enumerate(row): if e == 0: row_chars.append(num_symbols[row_num * 3 + i]) @@ -232,7 +240,7 @@ class TicTacToeMessageHandler: return row_string + '\n\n' def parse_board(self, board: Any) -> str: - ''' Takes the board as a nested list and returns a nice version for the user. ''' + '''Takes the board as a nested list and returns a nice version for the user.''' return "".join([self.parse_row(r, r_num) for r_num, r in enumerate(board)]) def get_player_color(self, turn: int) -> str: @@ -243,8 +251,9 @@ class TicTacToeMessageHandler: return '{} put a token at {}'.format(original_player, move_info) def game_start_message(self) -> str: - return ("Welcome to tic-tac-toe!" - "To make a move, type @-mention `move ` or ``") + return ( + "Welcome to tic-tac-toe!" "To make a move, type @-mention `move ` or ``" + ) class ticTacToeHandler(GameAdapter): @@ -252,6 +261,7 @@ class ticTacToeHandler(GameAdapter): You can play tic-tac-toe! Make sure your message starts with "@mention-bot". ''' + META = { 'name': 'TicTacToe', 'description': 'Lets you play Tic-tac-toe against a computer.', @@ -279,15 +289,15 @@ class ticTacToeHandler(GameAdapter): model, gameMessageHandler, rules, - supports_computer=True + supports_computer=True, ) def coords_from_command(cmd: str) -> str: # This function translates the input command into a TicTacToeGame move. # It should return two indices, each one of (1,2,3), separated by a comma, eg. "3,2" - ''' As there are various ways to input a coordinate (with/without parentheses, with/without spaces, etc.) the - input is stripped to just the numbers before being used in the program. ''' + '''As there are various ways to input a coordinate (with/without parentheses, with/without spaces, etc.) the + input is stripped to just the numbers before being used in the program.''' cmd_num = int(cmd.replace('move ', '')) - 1 cmd = '{},{}'.format((cmd_num % 3) + 1, (cmd_num // 3) + 1) return cmd diff --git a/zulip_bots/zulip_bots/bots/trello/test_trello.py b/zulip_bots/zulip_bots/bots/trello/test_trello.py index f8c11c3..91c5cdb 100644 --- a/zulip_bots/zulip_bots/bots/trello/test_trello.py +++ b/zulip_bots/zulip_bots/bots/trello/test_trello.py @@ -3,11 +3,8 @@ from unittest.mock import patch from zulip_bots.bots.trello.trello import TrelloHandler from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler -mock_config = { - 'api_key': 'TEST', - 'access_token': 'TEST', - 'user_name': 'TEST' -} +mock_config = {'api_key': 'TEST', 'access_token': 'TEST', 'user_name': 'TEST'} + class TestTrelloBot(BotTestCase, DefaultTests): bot_name = "trello" # type: str @@ -18,11 +15,14 @@ class TestTrelloBot(BotTestCase, DefaultTests): def test_bot_usage(self) -> None: with self.mock_config_info(mock_config), patch('requests.get'): - self.verify_reply('help', ''' + self.verify_reply( + 'help', + ''' This interactive bot can be used to interact with Trello. Use `list-commands` to get information about the supported commands. - ''') + ''', + ) def test_bot_quit_with_invalid_config(self) -> None: with self.mock_config_info(mock_config), self.assertRaises(StubBotHandler.BotQuitException): @@ -34,13 +34,15 @@ class TestTrelloBot(BotTestCase, DefaultTests): self.verify_reply('abcd', 'Command not supported') def test_list_commands_command(self) -> None: - expected_reply = ('**Commands:** \n' - '1. **help**: Get the bot usage information.\n' - '2. **list-commands**: Get information about the commands supported by the bot.\n' - '3. **get-all-boards**: Get all the boards under the configured account.\n' - '4. **get-all-cards **: Get all the cards in the given board.\n' - '5. **get-all-checklists **: Get all the checklists in the given card.\n' - '6. **get-all-lists **: Get all the lists in the given board.\n') + expected_reply = ( + '**Commands:** \n' + '1. **help**: Get the bot usage information.\n' + '2. **list-commands**: Get information about the commands supported by the bot.\n' + '3. **get-all-boards**: Get all the boards under the configured account.\n' + '4. **get-all-cards **: Get all the cards in the given board.\n' + '5. **get-all-checklists **: Get all the checklists in the given card.\n' + '6. **get-all-lists **: Get all the lists in the given board.\n' + ) with self.mock_config_info(mock_config), patch('requests.get'): self.verify_reply('list-commands', expected_reply) @@ -64,19 +66,21 @@ class TestTrelloBot(BotTestCase, DefaultTests): def test_get_all_checklists_command(self) -> None: with self.mock_config_info(mock_config), patch('requests.get'): with self.mock_http_conversation('get_checklists'): - self.verify_reply('get-all-checklists TEST', '**Checklists:**\n' - '1. `TEST`:\n' - ' * [X] TEST_1\n * [X] TEST_2\n' - ' * [-] TEST_3\n * [-] TEST_4') + self.verify_reply( + 'get-all-checklists TEST', + '**Checklists:**\n' + '1. `TEST`:\n' + ' * [X] TEST_1\n * [X] TEST_2\n' + ' * [-] TEST_3\n * [-] TEST_4', + ) def test_get_all_lists_command(self) -> None: with self.mock_config_info(mock_config), patch('requests.get'): with self.mock_http_conversation('get_lists'): - self.verify_reply('get-all-lists TEST', ('**Lists:**\n' - '1. TEST_A\n' - ' * TEST_1\n' - '2. TEST_B\n' - ' * TEST_2')) + self.verify_reply( + 'get-all-lists TEST', + ('**Lists:**\n' '1. TEST_A\n' ' * TEST_1\n' '2. TEST_B\n' ' * TEST_2'), + ) def test_command_exceptions(self) -> None: """Add appropriate tests here for all additional commands with try/except blocks. diff --git a/zulip_bots/zulip_bots/bots/trello/trello.py b/zulip_bots/zulip_bots/bots/trello/trello.py index b024e45..767fc16 100644 --- a/zulip_bots/zulip_bots/bots/trello/trello.py +++ b/zulip_bots/zulip_bots/bots/trello/trello.py @@ -10,12 +10,13 @@ supported_commands = [ ('get-all-boards', 'Get all the boards under the configured account.'), ('get-all-cards ', 'Get all the cards in the given board.'), ('get-all-checklists ', 'Get all the checklists in the given card.'), - ('get-all-lists ', 'Get all the lists in the given board.') + ('get-all-lists ', 'Get all the lists in the given board.'), ] INVALID_ARGUMENTS_ERROR_MESSAGE = 'Invalid Arguments.' RESPONSE_ERROR_MESSAGE = 'Invalid Response. Please check configuration and parameters.' + class TrelloHandler: def initialize(self, bot_handler: BotHandler) -> None: self.config_info = bot_handler.get_config_info('trello') @@ -23,16 +24,14 @@ class TrelloHandler: self.access_token = self.config_info['access_token'] self.user_name = self.config_info['user_name'] - self.auth_params = { - 'key': self.api_key, - 'token': self.access_token - } + self.auth_params = {'key': self.api_key, 'token': self.access_token} self.check_access_token(bot_handler) def check_access_token(self, bot_handler: BotHandler) -> None: - test_query_response = requests.get('https://api.trello.com/1/members/{}/'.format(self.user_name), - params=self.auth_params) + test_query_response = requests.get( + 'https://api.trello.com/1/members/{}/'.format(self.user_name), params=self.auth_params + ) if test_query_response.text == 'invalid key': bot_handler.quit('Invalid Credentials. Please see doc.md to find out how to get them.') @@ -97,10 +96,14 @@ class TrelloHandler: bot_response = [] # type: List[str] get_board_desc_url = 'https://api.trello.com/1/boards/{}/' for index, board in enumerate(boards): - board_desc_response = requests.get(get_board_desc_url.format(board), params=self.auth_params) + board_desc_response = requests.get( + get_board_desc_url.format(board), params=self.auth_params + ) board_data = board_desc_response.json() - bot_response += ['{_count}.[{name}]({url}) (`{id}`)'.format(_count=index + 1, **board_data)] + bot_response += [ + '{_count}.[{name}]({url}) (`{id}`)'.format(_count=index + 1, **board_data) + ] return '\n'.join(bot_response) @@ -116,7 +119,9 @@ class TrelloHandler: cards = cards_response.json() bot_response = ['**Cards:**'] for index, card in enumerate(cards): - bot_response += ['{_count}. [{name}]({url}) (`{id}`)'.format(_count=index + 1, **card)] + bot_response += [ + '{_count}. [{name}]({url}) (`{id}`)'.format(_count=index + 1, **card) + ] except (KeyError, ValueError, TypeError): return RESPONSE_ERROR_MESSAGE @@ -139,7 +144,11 @@ class TrelloHandler: if 'checkItems' in checklist: for item in checklist['checkItems']: - bot_response += [' * [{}] {}'.format('X' if item['state'] == 'complete' else '-', item['name'])] + bot_response += [ + ' * [{}] {}'.format( + 'X' if item['state'] == 'complete' else '-', item['name'] + ) + ] except (KeyError, ValueError, TypeError): return RESPONSE_ERROR_MESSAGE @@ -170,4 +179,5 @@ class TrelloHandler: return '\n'.join(bot_response) + handler_class = TrelloHandler diff --git a/zulip_bots/zulip_bots/bots/trivia_quiz/test_trivia_quiz.py b/zulip_bots/zulip_bots/bots/trivia_quiz/test_trivia_quiz.py index 7197401..08b87cc 100644 --- a/zulip_bots/zulip_bots/bots/trivia_quiz/test_trivia_quiz.py +++ b/zulip_bots/zulip_bots/bots/trivia_quiz/test_trivia_quiz.py @@ -17,12 +17,14 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, read_ class TestTriviaQuizBot(BotTestCase, DefaultTests): bot_name = "trivia_quiz" # type: str - new_question_response = '\nQ: Which class of animals are newts members of?\n\n' + \ - '* **A** Amphibian\n' + \ - '* **B** Fish\n' + \ - '* **C** Reptiles\n' + \ - '* **D** Mammals\n' + \ - '**reply**: answer Q001 ' + new_question_response = ( + '\nQ: Which class of animals are newts members of?\n\n' + + '* **A** Amphibian\n' + + '* **B** Fish\n' + + '* **C** Reptiles\n' + + '* **D** Mammals\n' + + '**reply**: answer Q001 ' + ) def get_test_quiz(self) -> Tuple[Dict[str, Any], Any]: bot_handler = StubBotHandler() @@ -58,13 +60,12 @@ class TestTriviaQuizBot(BotTestCase, DefaultTests): mock_html_unescape.side_effect = Exception with self.assertRaises(Exception) as exception: fix_quotes('test') - self.assertEqual(str(exception.exception), "Please use python3.4 or later for this bot.") + self.assertEqual( + str(exception.exception), "Please use python3.4 or later for this bot." + ) def test_invalid_answer(self) -> None: - invalid_replies = ['answer A', - 'answer A Q10', - 'answer Q001 K', - 'answer 001 A'] + invalid_replies = ['answer A', 'answer A Q10', 'answer Q001 K', 'answer 001 A'] for reply in invalid_replies: self._test(reply, 'Invalid answer format') @@ -84,13 +85,17 @@ class TestTriviaQuizBot(BotTestCase, DefaultTests): self.assertEqual(quiz['pending'], True) # test incorrect answer - with patch('zulip_bots.bots.trivia_quiz.trivia_quiz.get_quiz_from_id', - return_value=json.dumps(quiz)): + with patch( + 'zulip_bots.bots.trivia_quiz.trivia_quiz.get_quiz_from_id', + return_value=json.dumps(quiz), + ): self._test('answer Q001 B', ':disappointed: WRONG, Foo Test User! B is not correct.') # test correct answer - with patch('zulip_bots.bots.trivia_quiz.trivia_quiz.get_quiz_from_id', - return_value=json.dumps(quiz)): + with patch( + 'zulip_bots.bots.trivia_quiz.trivia_quiz.get_quiz_from_id', + return_value=json.dumps(quiz), + ): with patch('zulip_bots.bots.trivia_quiz.trivia_quiz.start_new_quiz'): self._test('answer Q001 A', ':tada: **Amphibian** is correct, Foo Test User!') @@ -128,7 +133,9 @@ class TestTriviaQuizBot(BotTestCase, DefaultTests): # test response and storage after three failed attempts start_new_question, response = handle_answer(quiz, 'D', 'Q001', bot_handler, 'Test User') - self.assertEqual(response, ':disappointed: WRONG, Test User! The correct answer is **Amphibian**.') + self.assertEqual( + response, ':disappointed: WRONG, Test User! The correct answer is **Amphibian**.' + ) self.assertTrue(start_new_question) quiz_reset = json.loads(bot_handler.storage.get('Q001')) self.assertEqual(quiz_reset['pending'], False) @@ -136,8 +143,12 @@ class TestTriviaQuizBot(BotTestCase, DefaultTests): # test response after question has ended incorrect_answers = ['B', 'C', 'D'] for ans in incorrect_answers: - start_new_question, response = handle_answer(quiz, ans, 'Q001', bot_handler, 'Test User') - self.assertEqual(response, ':disappointed: WRONG, Test User! The correct answer is **Amphibian**.') + start_new_question, response = handle_answer( + quiz, ans, 'Q001', bot_handler, 'Test User' + ) + self.assertEqual( + response, ':disappointed: WRONG, Test User! The correct answer is **Amphibian**.' + ) self.assertFalse(start_new_question) start_new_question, response = handle_answer(quiz, 'A', 'Q001', bot_handler, 'Test User') self.assertEqual(response, ':tada: **Amphibian** is correct, Test User!') diff --git a/zulip_bots/zulip_bots/bots/trivia_quiz/trivia_quiz.py b/zulip_bots/zulip_bots/bots/trivia_quiz/trivia_quiz.py index 4703d2c..468022a 100644 --- a/zulip_bots/zulip_bots/bots/trivia_quiz/trivia_quiz.py +++ b/zulip_bots/zulip_bots/bots/trivia_quiz/trivia_quiz.py @@ -12,9 +12,11 @@ from zulip_bots.lib import BotHandler class NotAvailableException(Exception): pass + class InvalidAnswerException(Exception): pass + class TriviaQuizHandler: def usage(self) -> str: return ''' @@ -45,8 +47,9 @@ class TriviaQuizHandler: bot_handler.send_reply(message, bot_response) return quiz = json.loads(quiz_payload) - start_new_question, bot_response = handle_answer(quiz, answer, quiz_id, - bot_handler, message['sender_full_name']) + start_new_question, bot_response = handle_answer( + quiz, answer, quiz_id, bot_handler, message['sender_full_name'] + ) bot_handler.send_reply(message, bot_response) if start_new_question: start_new_quiz(message, bot_handler) @@ -55,9 +58,11 @@ class TriviaQuizHandler: bot_response = 'type "new" for a new question' bot_handler.send_reply(message, bot_response) + def get_quiz_from_id(quiz_id: str, bot_handler: BotHandler) -> str: return bot_handler.storage.get(quiz_id) + def start_new_quiz(message: Dict[str, Any], bot_handler: BotHandler) -> None: quiz = get_trivia_quiz() quiz_id = generate_quiz_id(bot_handler.storage) @@ -66,6 +71,7 @@ def start_new_quiz(message: Dict[str, Any], bot_handler: BotHandler) -> None: bot_handler.storage.put(quiz_id, json.dumps(quiz)) bot_handler.send_reply(message, bot_response, widget_content) + def parse_answer(query: str) -> Tuple[str, str]: m = re.match(r'answer\s+(Q...)\s+(.)', query) if not m: @@ -78,11 +84,13 @@ def parse_answer(query: str) -> Tuple[str, str]: return (quiz_id, answer) + def get_trivia_quiz() -> Dict[str, Any]: payload = get_trivia_payload() quiz = get_quiz_from_payload(payload) return quiz + def get_trivia_payload() -> Dict[str, Any]: url = 'https://opentdb.com/api.php?amount=1&type=multiple' @@ -99,6 +107,7 @@ def get_trivia_payload() -> Dict[str, Any]: payload = data.json() return payload + def fix_quotes(s: str) -> Optional[str]: # opentdb is nice enough to escape HTML for us, but # we are sending this to code that does that already :) @@ -110,6 +119,7 @@ def fix_quotes(s: str) -> Optional[str]: except Exception: raise Exception('Please use python3.4 or later for this bot.') + def get_quiz_from_payload(payload: Dict[str, Any]) -> Dict[str, Any]: result = payload['results'][0] question = result['question'] @@ -119,12 +129,8 @@ def get_quiz_from_payload(payload: Dict[str, Any]) -> Dict[str, Any]: answers = dict() answers[correct_letter] = result['correct_answer'] for i in range(3): - answers[letters[i+1]] = result['incorrect_answers'][i] - answers = { - letter: fix_quotes(answer) - for letter, answer - in answers.items() - } + answers[letters[i + 1]] = result['incorrect_answers'][i] + answers = {letter: fix_quotes(answer) for letter, answer in answers.items()} quiz = dict( question=fix_quotes(question), answers=answers, @@ -134,6 +140,7 @@ def get_quiz_from_payload(payload: Dict[str, Any]) -> Dict[str, Any]: ) # type: Dict[str, Any] return quiz + def generate_quiz_id(storage: Any) -> str: try: quiz_num = storage.get('quiz_id') @@ -145,6 +152,7 @@ def generate_quiz_id(storage: Any) -> str: quiz_id = 'Q%03d' % (quiz_num,) return quiz_id + def format_quiz_for_widget(quiz_id: str, quiz: Dict[str, Any]) -> str: widget_type = 'zform' question = quiz['question'] @@ -178,16 +186,19 @@ def format_quiz_for_widget(quiz_id: str, quiz: Dict[str, Any]) -> str: payload = json.dumps(widget_content) return payload + def format_quiz_for_markdown(quiz_id: str, quiz: Dict[str, Any]) -> str: question = quiz['question'] answers = quiz['answers'] - answer_list = '\n'.join([ - '* **{letter}** {answer}'.format( - letter=letter, - answer=answers[letter], - ) - for letter in 'ABCD' - ]) + answer_list = '\n'.join( + [ + '* **{letter}** {answer}'.format( + letter=letter, + answer=answers[letter], + ) + for letter in 'ABCD' + ] + ) how_to_respond = '''**reply**: answer {quiz_id} '''.format(quiz_id=quiz_id) content = ''' @@ -201,9 +212,11 @@ Q: {question} ) return content + def update_quiz(quiz: Dict[str, Any], quiz_id: str, bot_handler: BotHandler) -> None: bot_handler.storage.put(quiz_id, json.dumps(quiz)) + def build_response(is_correct: bool, num_answers: int) -> str: if is_correct: response = ':tada: **{answer}** is correct, {sender_name}!' @@ -214,15 +227,17 @@ def build_response(is_correct: bool, num_answers: int) -> str: response = ':disappointed: WRONG, {sender_name}! {option} is not correct.' return response -def handle_answer(quiz: Dict[str, Any], option: str, quiz_id: str, - bot_handler: BotHandler, sender_name: str) -> Tuple[bool, str]: + +def handle_answer( + quiz: Dict[str, Any], option: str, quiz_id: str, bot_handler: BotHandler, sender_name: str +) -> Tuple[bool, str]: answer = quiz['answers'][quiz['correct_letter']] - is_new_answer = (option not in quiz['answered_options']) + is_new_answer = option not in quiz['answered_options'] if is_new_answer: quiz['answered_options'].append(option) num_answers = len(quiz['answered_options']) - is_correct = (option == quiz['correct_letter']) + is_correct = option == quiz['correct_letter'] start_new_question = quiz['pending'] and (is_correct or num_answers >= 3) if start_new_question or is_correct: @@ -232,7 +247,8 @@ def handle_answer(quiz: Dict[str, Any], option: str, quiz_id: str, update_quiz(quiz, quiz_id, bot_handler) response = build_response(is_correct, num_answers).format( - option=option, answer=answer, id=quiz_id, sender_name=sender_name) + option=option, answer=answer, id=quiz_id, sender_name=sender_name + ) return start_new_question, response diff --git a/zulip_bots/zulip_bots/bots/twitpost/test_twitpost.py b/zulip_bots/zulip_bots/bots/twitpost/test_twitpost.py index 11ee27d..34ec197 100644 --- a/zulip_bots/zulip_bots/bots/twitpost/test_twitpost.py +++ b/zulip_bots/zulip_bots/bots/twitpost/test_twitpost.py @@ -6,10 +6,12 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_b class TestTwitpostBot(BotTestCase, DefaultTests): bot_name = "twitpost" - mock_config = {'consumer_key': 'abcdefghijklmnopqrstuvwxy', - 'consumer_secret': 'aabbccddeeffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyy', - 'access_token': '123456789012345678-ABCDefgh1234afdsa678lKj6gHhslsi', - 'access_token_secret': 'yf0SI0x6Ct2OmF0cDQc1E0eLKXrVAPFx4QkZF2f9PfFCt'} + mock_config = { + 'consumer_key': 'abcdefghijklmnopqrstuvwxy', + 'consumer_secret': 'aabbccddeeffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyy', + 'access_token': '123456789012345678-ABCDefgh1234afdsa678lKj6gHhslsi', + 'access_token_secret': 'yf0SI0x6Ct2OmF0cDQc1E0eLKXrVAPFx4QkZF2f9PfFCt', + } api_response = read_bot_fixture_data('twitpost', 'api_response') def test_bot_usage(self) -> None: @@ -27,12 +29,14 @@ class TestTwitpostBot(BotTestCase, DefaultTests): def test_help(self) -> None: with self.mock_config_info(self.mock_config): - self.verify_reply('help', - "*Help for Twitter-post bot* :twitter: : \n\n" - "The bot tweets on twitter when message starts with @twitpost.\n\n" - "`@twitpost tweet ` will tweet on twitter with given ``.\n" - "Example:\n" - " * @twitpost tweet hey batman\n") + self.verify_reply( + 'help', + "*Help for Twitter-post bot* :twitter: : \n\n" + "The bot tweets on twitter when message starts with @twitpost.\n\n" + "`@twitpost tweet ` will tweet on twitter with given ``.\n" + "Example:\n" + " * @twitpost tweet hey batman\n", + ) @patch('tweepy.API.update_status', return_value=api_response) def test_tweet(self, mockedarg): diff --git a/zulip_bots/zulip_bots/bots/twitpost/twitpost.py b/zulip_bots/zulip_bots/bots/twitpost/twitpost.py index ab61a0d..fd0683f 100644 --- a/zulip_bots/zulip_bots/bots/twitpost/twitpost.py +++ b/zulip_bots/zulip_bots/bots/twitpost/twitpost.py @@ -6,25 +6,29 @@ from zulip_bots.lib import BotHandler class TwitpostBot: - def usage(self) -> str: return ''' This bot posts on twitter from zulip chat itself. Use '@twitpost help' to get more information on the bot usage. ''' - help_content = "*Help for Twitter-post bot* :twitter: : \n\n"\ - "The bot tweets on twitter when message starts "\ - "with @twitpost.\n\n"\ - "`@twitpost tweet ` will tweet on twitter " \ - "with given ``.\n" \ - "Example:\n" \ - " * @twitpost tweet hey batman\n" + + help_content = ( + "*Help for Twitter-post bot* :twitter: : \n\n" + "The bot tweets on twitter when message starts " + "with @twitpost.\n\n" + "`@twitpost tweet ` will tweet on twitter " + "with given ``.\n" + "Example:\n" + " * @twitpost tweet hey batman\n" + ) def initialize(self, bot_handler: BotHandler) -> None: self.config_info = bot_handler.get_config_info('twitter') - auth = tweepy.OAuthHandler(self.config_info['consumer_key'], - self.config_info['consumer_secret']) - auth.set_access_token(self.config_info['access_token'], - self.config_info['access_token_secret']) + auth = tweepy.OAuthHandler( + self.config_info['consumer_key'], self.config_info['consumer_secret'] + ) + auth.set_access_token( + self.config_info['access_token'], self.config_info['access_token_secret'] + ) self.api = tweepy.API(auth, parser=tweepy.parsers.JSONParser()) def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: @@ -44,8 +48,7 @@ class TwitpostBot: status = self.post(" ".join(content[1:])) screen_name = status["user"]["screen_name"] id_str = status["id_str"] - bot_reply = "https://twitter.com/{}/status/{}".format(screen_name, - id_str) + bot_reply = "https://twitter.com/{}/status/{}".format(screen_name, id_str) bot_reply = "Tweet Posted\n" + bot_reply bot_handler.send_reply(message, bot_reply) diff --git a/zulip_bots/zulip_bots/bots/virtual_fs/test_virtual_fs.py b/zulip_bots/zulip_bots/bots/virtual_fs/test_virtual_fs.py index b91baae..3801f7f 100755 --- a/zulip_bots/zulip_bots/bots/virtual_fs/test_virtual_fs.py +++ b/zulip_bots/zulip_bots/bots/virtual_fs/test_virtual_fs.py @@ -6,21 +6,23 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests class TestVirtualFsBot(BotTestCase, DefaultTests): bot_name = "virtual_fs" - help_txt = ('foo@example.com:\n\nThis bot implements a virtual file system for a stream.\n' - 'The locations of text are persisted for the lifetime of the bot\n' - 'running, and if you rename a stream, you will lose the info.\n' - 'Example commands:\n\n```\n' - '@mention-bot sample_conversation: sample conversation with the bot\n' - '@mention-bot mkdir: create a directory\n' - '@mention-bot ls: list a directory\n' - '@mention-bot cd: change directory\n' - '@mention-bot pwd: show current path\n' - '@mention-bot write: write text\n' - '@mention-bot read: read text\n' - '@mention-bot rm: remove a file\n' - '@mention-bot rmdir: remove a directory\n' - '```\n' - 'Use commands like `@mention-bot help write` for more details on specific\ncommands.\n') + help_txt = ( + 'foo@example.com:\n\nThis bot implements a virtual file system for a stream.\n' + 'The locations of text are persisted for the lifetime of the bot\n' + 'running, and if you rename a stream, you will lose the info.\n' + 'Example commands:\n\n```\n' + '@mention-bot sample_conversation: sample conversation with the bot\n' + '@mention-bot mkdir: create a directory\n' + '@mention-bot ls: list a directory\n' + '@mention-bot cd: change directory\n' + '@mention-bot pwd: show current path\n' + '@mention-bot write: write text\n' + '@mention-bot read: read text\n' + '@mention-bot rm: remove a file\n' + '@mention-bot rmdir: remove a directory\n' + '```\n' + 'Use commands like `@mention-bot help write` for more details on specific\ncommands.\n' + ) def test_multiple_recipient_conversation(self) -> None: expected = [ @@ -52,9 +54,7 @@ class TestVirtualFsBot(BotTestCase, DefaultTests): # for the user's benefit if they ask. But then we can also # use it to test that the bot works as advertised. expected = [ - (request, 'foo@example.com:\n' + reply) - for (request, reply) - in sample_conversation() + (request, 'foo@example.com:\n' + reply) for (request, reply) in sample_conversation() ] self.verify_dialog(expected) diff --git a/zulip_bots/zulip_bots/bots/virtual_fs/virtual_fs.py b/zulip_bots/zulip_bots/bots/virtual_fs/virtual_fs.py index 85c0d2c..5b7968c 100644 --- a/zulip_bots/zulip_bots/bots/virtual_fs/virtual_fs.py +++ b/zulip_bots/zulip_bots/bots/virtual_fs/virtual_fs.py @@ -63,6 +63,7 @@ Use commands like `@mention-bot help write` for more details on specific commands. ''' + def sample_conversation() -> List[Tuple[str, str]]: return [ ('cd /', 'Current path: /'), @@ -112,6 +113,7 @@ def sample_conversation() -> List[Tuple[str, str]]: ('write', 'ERROR: syntax: write '), ] + REGEXES = dict( command='(cd|ls|mkdir|read|rmdir|rm|write|pwd)', path=r'(\S+)', @@ -119,6 +121,7 @@ REGEXES = dict( some_text='(.+)', ) + def get_commands() -> Dict[str, Tuple[Any, List[str]]]: return { 'help': (fs_help, ['command']), @@ -132,19 +135,16 @@ def get_commands() -> Dict[str, Tuple[Any, List[str]]]: 'pwd': (fs_pwd, []), } + def fs_command(fs: str, user: str, cmd: str) -> Tuple[str, Any]: cmd = cmd.strip() if cmd == 'help': return fs, get_help() if cmd == 'sample_conversation': - sample = '\n\n'.join( - '\n'.join(tup) - for tup - in sample_conversation() - ) + sample = '\n\n'.join('\n'.join(tup) for tup in sample_conversation()) return fs, sample cmd_name = cmd.split()[0] - cmd_args = cmd[len(cmd_name):].strip() + cmd_args = cmd[len(cmd_name) :].strip() commands = get_commands() if cmd_name not in commands: return fs, 'ERROR: unrecognized command' @@ -161,6 +161,7 @@ def fs_command(fs: str, user: str, cmd: str) -> Tuple[str, Any]: else: return fs, 'ERROR: ' + syntax_help(cmd_name) + def syntax_help(cmd_name: str) -> str: commands = get_commands() f, arg_names = commands[cmd_name] @@ -171,16 +172,16 @@ def syntax_help(cmd_name: str) -> str: cmd = cmd_name return 'syntax: {}'.format(cmd) + def fs_new() -> Dict[str, Any]: - fs = { - '/': directory([]), - 'user_paths': dict() - } + fs = {'/': directory([]), 'user_paths': dict()} return fs + def fs_help(fs: Dict[str, Any], user: str, cmd_name: str) -> Tuple[Dict[str, Any], Any]: return fs, syntax_help(cmd_name) + def fs_mkdir(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: path, msg = make_path(fs, user, fn) if msg: @@ -198,6 +199,7 @@ def fs_mkdir(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], An msg = 'directory created' return new_fs, msg + def fs_ls(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: if fn == '.' or fn == '': path = fs['user_paths'][user] @@ -216,11 +218,13 @@ def fs_ls(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: msg = '\n'.join('* ' + nice_path(fs, path) for path in sorted(fns)) return fs, msg + def fs_pwd(fs: Dict[str, Any], user: str) -> Tuple[Dict[str, Any], Any]: path = fs['user_paths'][user] msg = nice_path(fs, path) return fs, msg + def fs_rm(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: path, msg = make_path(fs, user, fn) if msg: @@ -238,6 +242,7 @@ def fs_rm(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: msg = 'removed' return new_fs, msg + def fs_rmdir(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: path, msg = make_path(fs, user, fn) if msg: @@ -253,11 +258,12 @@ def fs_rmdir(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], An directory = get_directory(path) new_fs[directory]['fns'].remove(path) for sub_path in list(new_fs.keys()): - if sub_path.startswith(path+'/'): + if sub_path.startswith(path + '/'): new_fs.pop(sub_path) msg = 'removed' return new_fs, msg + def fs_write(fs: Dict[str, Any], user: str, fn: str, content: str) -> Tuple[Dict[str, Any], Any]: path, msg = make_path(fs, user, fn) if msg: @@ -276,6 +282,7 @@ def fs_write(fs: Dict[str, Any], user: str, fn: str, content: str) -> Tuple[Dict msg = 'file written' return new_fs, msg + def fs_read(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: path, msg = make_path(fs, user, fn) if msg: @@ -289,6 +296,7 @@ def fs_read(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any val = fs[path]['content'] return fs, val + def fs_cd(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: if len(fn) > 1 and fn[-1] == '/': fn = fn[:-1] @@ -302,6 +310,7 @@ def fs_cd(fs: Dict[str, Any], user: str, fn: str) -> Tuple[Dict[str, Any], Any]: fs['user_paths'][user] = path return fs, "Current path: {}".format(nice_path(fs, path)) + def make_path(fs: Dict[str, Any], user: str, leaf: str) -> List[str]: if leaf == '/': return ['/', ''] @@ -315,17 +324,19 @@ def make_path(fs: Dict[str, Any], user: str, leaf: str) -> List[str]: path += leaf return [path, ''] + def nice_path(fs: Dict[str, Any], path: str) -> str: path_nice = path slash = path.rfind('/') if path not in fs: return 'ERROR: the current directory does not exist' if fs[path]['kind'] == 'text': - path_nice = '{}*{}*'.format(path[:slash+1], path[slash+1:]) + path_nice = '{}*{}*'.format(path[: slash + 1], path[slash + 1 :]) elif path != '/': path_nice = '{}/'.format(path) return path_nice + def get_directory(path: str) -> str: slash = path.rfind('/') if slash == 0: @@ -333,15 +344,19 @@ def get_directory(path: str) -> str: else: return path[:slash] + def directory(fns: Union[Set[str], List[Any]]) -> Dict[str, Union[str, List[Any]]]: return dict(kind='dir', fns=list(fns)) + def text_file(content: str) -> Dict[str, str]: return dict(kind='text', content=content) + def is_directory(fs: Dict[str, Any], fn: str) -> bool: if fn not in fs: return False return fs[fn]['kind'] == 'dir' + handler_class = VirtualFsHandler diff --git a/zulip_bots/zulip_bots/bots/weather/weather.py b/zulip_bots/zulip_bots/bots/weather/weather.py index 9f08f76..3551311 100644 --- a/zulip_bots/zulip_bots/bots/weather/weather.py +++ b/zulip_bots/zulip_bots/bots/weather/weather.py @@ -7,6 +7,7 @@ from zulip_bots.lib import BotHandler api_url = 'http://api.openweathermap.org/data/2.5/weather' + class WeatherHandler: def initialize(self, bot_handler: BotHandler) -> None: self.api_key = bot_handler.get_config_info('weather')['key'] @@ -68,6 +69,7 @@ def to_celsius(temp_kelvin: float) -> float: def to_fahrenheit(temp_kelvin: float) -> float: - return int(temp_kelvin) * (9. / 5.) - 459.67 + return int(temp_kelvin) * (9.0 / 5.0) - 459.67 + handler_class = WeatherHandler diff --git a/zulip_bots/zulip_bots/bots/wikipedia/test_wikipedia.py b/zulip_bots/zulip_bots/bots/wikipedia/test_wikipedia.py index 384d342..3320788 100755 --- a/zulip_bots/zulip_bots/bots/wikipedia/test_wikipedia.py +++ b/zulip_bots/zulip_bots/bots/wikipedia/test_wikipedia.py @@ -9,31 +9,31 @@ class TestWikipediaBot(BotTestCase, DefaultTests): # Single-word query bot_request = 'happy' - bot_response = ('''For search term:happy + bot_response = '''For search term:happy 1:[Happiness](https://en.wikipedia.org/wiki/Happiness) 2:[Happy!](https://en.wikipedia.org/wiki/Happy!) 3:[Happy,_Happy](https://en.wikipedia.org/wiki/Happy,_Happy) -''') +''' with self.mock_http_conversation('test_single_word'): self.verify_reply(bot_request, bot_response) # Multi-word query bot_request = 'The sky is blue' - bot_response = ('''For search term:The sky is blue + bot_response = '''For search term:The sky is blue 1:[Sky_blue](https://en.wikipedia.org/wiki/Sky_blue) 2:[Sky_Blue_Sky](https://en.wikipedia.org/wiki/Sky_Blue_Sky) 3:[Blue_Sky](https://en.wikipedia.org/wiki/Blue_Sky) -''') +''' with self.mock_http_conversation('test_multi_word'): self.verify_reply(bot_request, bot_response) # Number query bot_request = '123' - bot_response = ('''For search term:123 + bot_response = '''For search term:123 1:[123](https://en.wikipedia.org/wiki/123) 2:[Japan_Airlines_Flight_123](https://en.wikipedia.org/wiki/Japan_Airlines_Flight_123) 3:[Iodine-123](https://en.wikipedia.org/wiki/Iodine-123) -''') +''' with self.mock_http_conversation('test_number_query'): self.verify_reply(bot_request, bot_response) @@ -47,7 +47,9 @@ class TestWikipediaBot(BotTestCase, DefaultTests): # Incorrect word bot_request = 'sssssss kkkkk' - bot_response = "I am sorry. The search term you provided is not found :slightly_frowning_face:" + bot_response = ( + "I am sorry. The search term you provided is not found :slightly_frowning_face:" + ) with self.mock_http_conversation('test_incorrect_query'): self.verify_reply(bot_request, bot_response) @@ -58,8 +60,10 @@ class TestWikipediaBot(BotTestCase, DefaultTests): # Incorrect status code bot_request = 'Zulip' - bot_response = 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' \ - 'Please try again later.' + bot_response = ( + 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' + 'Please try again later.' + ) with self.mock_http_conversation('test_status_code'): self.verify_reply(bot_request, bot_response) diff --git a/zulip_bots/zulip_bots/bots/wikipedia/wikipedia.py b/zulip_bots/zulip_bots/bots/wikipedia/wikipedia.py index 1524d09..f296165 100644 --- a/zulip_bots/zulip_bots/bots/wikipedia/wikipedia.py +++ b/zulip_bots/zulip_bots/bots/wikipedia/wikipedia.py @@ -7,6 +7,7 @@ from zulip_bots.lib import BotHandler # See readme.md for instructions on running this code. + class WikipediaHandler: ''' This plugin facilitates searching Wikipedia for a @@ -47,36 +48,47 @@ class WikipediaHandler: return help_text.format(bot_handler.identity().mention) query_wiki_url = 'https://en.wikipedia.org/w/api.php' - query_wiki_params = dict( - action='query', - list='search', - srsearch=query, - format='json' - ) + query_wiki_params = dict(action='query', list='search', srsearch=query, format='json') try: data = requests.get(query_wiki_url, params=query_wiki_params) except requests.exceptions.RequestException: logging.error('broken link') - return 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' \ - 'Please try again later.' + return ( + 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' + 'Please try again later.' + ) # Checking if the bot accessed the link. if data.status_code != 200: logging.error('Page not found.') - return 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' \ - 'Please try again later.' + return ( + 'Uh-Oh ! Sorry ,couldn\'t process the request right now.:slightly_frowning_face:\n' + 'Please try again later.' + ) new_content = 'For search term:' + query + '\n' # Checking if there is content for the searched term if len(data.json()['query']['search']) == 0: - new_content = 'I am sorry. The search term you provided is not found :slightly_frowning_face:' + new_content = ( + 'I am sorry. The search term you provided is not found :slightly_frowning_face:' + ) else: for i in range(min(3, len(data.json()['query']['search']))): search_string = data.json()['query']['search'][i]['title'].replace(' ', '_') url = 'https://en.wikipedia.org/wiki/' + search_string - new_content += str(i+1) + ':' + '[' + search_string + ']' + '(' + url.replace('"', "%22") + ')\n' + new_content += ( + str(i + 1) + + ':' + + '[' + + search_string + + ']' + + '(' + + url.replace('"', "%22") + + ')\n' + ) return new_content + handler_class = WikipediaHandler diff --git a/zulip_bots/zulip_bots/bots/witai/test_witai.py b/zulip_bots/zulip_bots/bots/witai/test_witai.py index cd87518..85869ca 100644 --- a/zulip_bots/zulip_bots/bots/witai/test_witai.py +++ b/zulip_bots/zulip_bots/bots/witai/test_witai.py @@ -10,17 +10,12 @@ class TestWitaiBot(BotTestCase, DefaultTests): MOCK_CONFIG_INFO = { 'token': '12345678', 'handler_location': '/Users/abcd/efgh', - 'help_message': 'Qwertyuiop!' + 'help_message': 'Qwertyuiop!', } MOCK_WITAI_RESPONSE = { '_text': 'What is your favorite food?', - 'entities': { - 'intent': [{ - 'confidence': 1.0, - 'value': 'favorite_food' - }] - } + 'entities': {'intent': [{'confidence': 1.0, 'value': 'favorite_food'}]}, } def test_normal(self) -> None: @@ -29,10 +24,7 @@ class TestWitaiBot(BotTestCase, DefaultTests): get_bot_message_handler(self.bot_name).initialize(StubBotHandler()) with patch('wit.Wit.message', return_value=self.MOCK_WITAI_RESPONSE): - self.verify_reply( - 'What is your favorite food?', - 'pizza' - ) + self.verify_reply('What is your favorite food?', 'pizza') # This overrides the default one in `BotTestCase`. def test_bot_responds_to_empty_message(self) -> None: @@ -42,6 +34,7 @@ class TestWitaiBot(BotTestCase, DefaultTests): with patch('wit.Wit.message', return_value=self.MOCK_WITAI_RESPONSE): self.verify_reply('', 'Qwertyuiop!') + def mock_handle(res: Dict[str, Any]) -> Optional[str]: if res['entities']['intent'][0]['value'] == 'favorite_food': return 'pizza' diff --git a/zulip_bots/zulip_bots/bots/witai/witai.py b/zulip_bots/zulip_bots/bots/witai/witai.py index 30c7285..9a4da37 100644 --- a/zulip_bots/zulip_bots/bots/witai/witai.py +++ b/zulip_bots/zulip_bots/bots/witai/witai.py @@ -59,8 +59,10 @@ class WitaiHandler: print(e) return + handler_class = WitaiHandler + def get_handle(location: str) -> Optional[Callable[[Dict[str, Any]], Optional[str]]]: '''Returns a function to be used when generating a response from Wit.ai bot. This function is the function named `handle` in the module at the diff --git a/zulip_bots/zulip_bots/bots/xkcd/test_xkcd.py b/zulip_bots/zulip_bots/bots/xkcd/test_xkcd.py index c3d7f53..f3029f4 100755 --- a/zulip_bots/zulip_bots/bots/xkcd/test_xkcd.py +++ b/zulip_bots/zulip_bots/bots/xkcd/test_xkcd.py @@ -7,17 +7,21 @@ class TestXkcdBot(BotTestCase, DefaultTests): bot_name = "xkcd" def test_latest_command(self) -> None: - bot_response = ("#1866: **Russell's Teapot**\n" - "[Unfortunately, NASA regulations state that Bertrand Russell-related " - "payloads can only be launched within launch vehicles which do not launch " - "themselves.](https://imgs.xkcd.com/comics/russells_teapot.png)") + bot_response = ( + "#1866: **Russell's Teapot**\n" + "[Unfortunately, NASA regulations state that Bertrand Russell-related " + "payloads can only be launched within launch vehicles which do not launch " + "themselves.](https://imgs.xkcd.com/comics/russells_teapot.png)" + ) with self.mock_http_conversation('test_latest'): self.verify_reply('latest', bot_response) def test_random_command(self) -> None: - bot_response = ("#1800: **Chess Notation**\n" - "[I've decided to score all my conversations using chess win-loss " - "notation. (??)](https://imgs.xkcd.com/comics/chess_notation.png)") + bot_response = ( + "#1800: **Chess Notation**\n" + "[I've decided to score all my conversations using chess win-loss " + "notation. (??)](https://imgs.xkcd.com/comics/chess_notation.png)" + ) with self.mock_http_conversation('test_random'): # Mock randint function. with patch('zulip_bots.bots.xkcd.xkcd.random.randint') as randint: @@ -27,8 +31,10 @@ class TestXkcdBot(BotTestCase, DefaultTests): self.verify_reply('random', bot_response) def test_numeric_comic_id_command_1(self) -> None: - bot_response = ("#1: **Barrel - Part 1**\n[Don't we all.]" - "(https://imgs.xkcd.com/comics/barrel_cropped_(1).jpg)") + bot_response = ( + "#1: **Barrel - Part 1**\n[Don't we all.]" + "(https://imgs.xkcd.com/comics/barrel_cropped_(1).jpg)" + ) with self.mock_http_conversation('test_specific_id'): self.verify_reply('1', bot_response) @@ -36,20 +42,23 @@ class TestXkcdBot(BotTestCase, DefaultTests): def test_invalid_comic_ids(self, mock_logging_exception: MagicMock) -> None: invalid_id_txt = "Sorry, there is likely no xkcd comic strip with id: #" - for comic_id, fixture in (('0', 'test_not_existing_id_2'), - ('999999999', 'test_not_existing_id')): + for comic_id, fixture in ( + ('0', 'test_not_existing_id_2'), + ('999999999', 'test_not_existing_id'), + ): with self.mock_http_conversation(fixture): self.verify_reply(comic_id, invalid_id_txt + comic_id) def test_help_responses(self) -> None: help_txt = "xkcd bot supports these commands:" - err_txt = "xkcd bot only supports these commands, not `{}`:" + err_txt = "xkcd bot only supports these commands, not `{}`:" commands = ''' * `{0} help` to show this help message. * `{0} latest` to fetch the latest comic strip from xkcd. * `{0} random` to fetch a random comic strip from xkcd. * `{0} ` to fetch a comic strip based on `` e.g `{0} 1234`.'''.format( - "@**test-bot**") + "@**test-bot**" + ) self.verify_reply('', err_txt.format('') + commands) self.verify_reply('help', help_txt + commands) # Example invalid command diff --git a/zulip_bots/zulip_bots/bots/xkcd/xkcd.py b/zulip_bots/zulip_bots/bots/xkcd/xkcd.py index 68587ef..4caab6a 100644 --- a/zulip_bots/zulip_bots/bots/xkcd/xkcd.py +++ b/zulip_bots/zulip_bots/bots/xkcd/xkcd.py @@ -9,6 +9,7 @@ from zulip_bots.lib import BotHandler XKCD_TEMPLATE_URL = 'https://xkcd.com/%s/info.0.json' LATEST_XKCD_URL = 'https://xkcd.com/info.0.json' + class XkcdHandler: ''' This plugin provides several commands that can be used for fetch a comic @@ -40,27 +41,33 @@ class XkcdHandler: xkcd_bot_response = get_xkcd_bot_response(message, quoted_name) bot_handler.send_reply(message, xkcd_bot_response) + class XkcdBotCommand: LATEST = 0 RANDOM = 1 COMIC_ID = 2 + class XkcdNotFoundError(Exception): pass + class XkcdServerError(Exception): pass + def get_xkcd_bot_response(message: Dict[str, str], quoted_name: str) -> str: original_content = message['content'].strip() command = original_content.strip() - commands_help = ("%s" - "\n* `{0} help` to show this help message." - "\n* `{0} latest` to fetch the latest comic strip from xkcd." - "\n* `{0} random` to fetch a random comic strip from xkcd." - "\n* `{0} ` to fetch a comic strip based on `` " - "e.g `{0} 1234`.".format(quoted_name)) + commands_help = ( + "%s" + "\n* `{0} help` to show this help message." + "\n* `{0} latest` to fetch the latest comic strip from xkcd." + "\n* `{0} random` to fetch a random comic strip from xkcd." + "\n* `{0} ` to fetch a comic strip based on `` " + "e.g `{0} 1234`.".format(quoted_name) + ) try: if command == 'help': @@ -72,19 +79,25 @@ def get_xkcd_bot_response(message: Dict[str, str], quoted_name: str) -> str: elif command.isdigit(): fetched = fetch_xkcd_query(XkcdBotCommand.COMIC_ID, command) else: - return commands_help % ("xkcd bot only supports these commands, not `%s`:" % (command,),) + return commands_help % ( + "xkcd bot only supports these commands, not `%s`:" % (command,), + ) except (requests.exceptions.ConnectionError, XkcdServerError): logging.exception('Connection error occurred when trying to connect to xkcd server') return 'Sorry, I cannot process your request right now, please try again later!' except XkcdNotFoundError: - logging.exception('XKCD server responded 404 when trying to fetch comic with id %s' - % (command,)) + logging.exception( + 'XKCD server responded 404 when trying to fetch comic with id %s' % (command,) + ) return 'Sorry, there is likely no xkcd comic strip with id: #%s' % (command,) else: - return ("#%s: **%s**\n[%s](%s)" % (fetched['num'], - fetched['title'], - fetched['alt'], - fetched['img'])) + return "#%s: **%s**\n[%s](%s)" % ( + fetched['num'], + fetched['title'], + fetched['alt'], + fetched['img'], + ) + def fetch_xkcd_query(mode: int, comic_id: Optional[str] = None) -> Dict[str, str]: try: @@ -120,4 +133,5 @@ def fetch_xkcd_query(mode: int, comic_id: Optional[str] = None) -> Dict[str, str return xkcd_json + handler_class = XkcdHandler diff --git a/zulip_bots/zulip_bots/bots/yoda/test_yoda.py b/zulip_bots/zulip_bots/bots/yoda/test_yoda.py index 2db1e2b..fcfa41c 100644 --- a/zulip_bots/zulip_bots/bots/yoda/test_yoda.py +++ b/zulip_bots/zulip_bots/bots/yoda/test_yoda.py @@ -35,39 +35,46 @@ class TestYodaBot(BotTestCase, DefaultTests): def test_bot(self) -> None: # Test normal sentence (1). - self._test('You will learn how to speak like me someday.', - "Learn how to speak like me someday, you will. Yes, hmmm.", - 'test_1') + self._test( + 'You will learn how to speak like me someday.', + "Learn how to speak like me someday, you will. Yes, hmmm.", + 'test_1', + ) # Test normal sentence (2). - self._test('you still have much to learn', - "Much to learn, you still have.", - 'test_2') + self._test('you still have much to learn', "Much to learn, you still have.", 'test_2') # Test only numbers. - self._test('23456', "23456. Herh herh herh.", - 'test_only_numbers') + self._test('23456', "23456. Herh herh herh.", 'test_only_numbers') # Test help. self._test('help', self.help_text) # Test invalid input. - self._test('@#$%^&*', - "Invalid input, please check the sentence you have entered.", - 'test_invalid_input') + self._test( + '@#$%^&*', + "Invalid input, please check the sentence you have entered.", + 'test_invalid_input', + ) # Test 403 response. - self._test('You will learn how to speak like me someday.', - "Invalid Api Key. Did you follow the instructions in the `readme.md` file?", - 'test_api_key_error') + self._test( + 'You will learn how to speak like me someday.', + "Invalid Api Key. Did you follow the instructions in the `readme.md` file?", + 'test_api_key_error', + ) # Test 503 response. with self.assertRaises(ServiceUnavailableError): - self._test('You will learn how to speak like me someday.', - "The service is temporarily unavailable, please try again.", - 'test_service_unavailable_error') + self._test( + 'You will learn how to speak like me someday.', + "The service is temporarily unavailable, please try again.", + 'test_service_unavailable_error', + ) # Test unknown response. - self._test('You will learn how to speak like me someday.', - "Unknown Error.Error code: 123 Did you follow the instructions in the `readme.md` file?", - 'test_unknown_error') + self._test( + 'You will learn how to speak like me someday.', + "Unknown Error.Error code: 123 Did you follow the instructions in the `readme.md` file?", + 'test_unknown_error', + ) diff --git a/zulip_bots/zulip_bots/bots/yoda/yoda.py b/zulip_bots/zulip_bots/bots/yoda/yoda.py index 227b6ce..cf9c1fc 100644 --- a/zulip_bots/zulip_bots/bots/yoda/yoda.py +++ b/zulip_bots/zulip_bots/bots/yoda/yoda.py @@ -25,6 +25,7 @@ HELP_MESSAGE = ''' class ApiKeyError(Exception): '''raise this when there is an error with the Mashape Api Key''' + class ServiceUnavailableError(Exception): '''raise this when the service is unavailable.''' @@ -34,6 +35,7 @@ class YodaSpeakHandler: This bot will allow users to translate a sentence into 'Yoda speak'. It looks for messages starting with '@mention-bot'. ''' + def initialize(self, bot_handler: BotHandler) -> None: self.api_key = bot_handler.get_config_info('yoda')['api_key'] @@ -56,13 +58,11 @@ class YodaSpeakHandler: def send_to_yoda_api(self, sentence: str) -> str: # function for sending sentence to api - response = requests.get("https://yoda.p.mashape.com/yoda", - params=dict(sentence=sentence), - headers={ - "X-Mashape-Key": self.api_key, - "Accept": "text/plain" - } - ) + response = requests.get( + "https://yoda.p.mashape.com/yoda", + params=dict(sentence=sentence), + headers={"X-Mashape-Key": self.api_key, "Accept": "text/plain"}, + ) if response.status_code == 200: return response.json()['text'] @@ -74,8 +74,12 @@ class YodaSpeakHandler: error_message = response.json()['message'] logging.error(error_message) error_code = response.status_code - error_message = error_message + 'Error code: ' + str(error_code) +\ - ' Did you follow the instructions in the `readme.md` file?' + error_message = ( + error_message + + 'Error code: ' + + str(error_code) + + ' Did you follow the instructions in the `readme.md` file?' + ) return error_message def format_input(self, original_content: str) -> str: @@ -104,19 +108,18 @@ class YodaSpeakHandler: logging.error(reply_message) except ApiKeyError: - reply_message = 'Invalid Api Key. Did you follow the instructions in the `readme.md` file?' + reply_message = ( + 'Invalid Api Key. Did you follow the instructions in the `readme.md` file?' + ) logging.error(reply_message) bot_handler.send_reply(message, reply_message) - def send_message(self, bot_handler: BotHandler, message: str, stream: str, subject: str) -> None: + def send_message( + self, bot_handler: BotHandler, message: str, stream: str, subject: str + ) -> None: # function for sending a message - bot_handler.send_message(dict( - type='stream', - to=stream, - subject=subject, - content=message - )) + bot_handler.send_message(dict(type='stream', to=stream, subject=subject, content=message)) def is_help(self, original_content: str) -> bool: # gets rid of whitespace around the edges, so that they aren't a problem in the future @@ -126,4 +129,5 @@ class YodaSpeakHandler: else: return False + handler_class = YodaSpeakHandler diff --git a/zulip_bots/zulip_bots/bots/youtube/test_youtube.py b/zulip_bots/zulip_bots/bots/youtube/test_youtube.py index 8d55f11..b5b997a 100644 --- a/zulip_bots/zulip_bots/bots/youtube/test_youtube.py +++ b/zulip_bots/zulip_bots/bots/youtube/test_youtube.py @@ -8,87 +8,102 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_b class TestYoutubeBot(BotTestCase, DefaultTests): bot_name = "youtube" - normal_config = {'key': '12345678', - 'number_of_results': '5', - 'video_region': 'US'} # type: Dict[str,str] + normal_config = { + 'key': '12345678', + 'number_of_results': '5', + 'video_region': 'US', + } # type: Dict[str,str] - help_content = "*Help for YouTube bot* :robot_face: : \n\n" \ - "The bot responds to messages starting with @mention-bot.\n\n" \ - "`@mention-bot ` will return top Youtube video for the given ``.\n" \ - "`@mention-bot top ` also returns the top Youtube result.\n" \ - "`@mention-bot list ` will return a list Youtube videos for the given .\n \n" \ - "Example:\n" \ - " * @mention-bot funny cats\n" \ - " * @mention-bot list funny dogs" + help_content = ( + "*Help for YouTube bot* :robot_face: : \n\n" + "The bot responds to messages starting with @mention-bot.\n\n" + "`@mention-bot ` will return top Youtube video for the given ``.\n" + "`@mention-bot top ` also returns the top Youtube result.\n" + "`@mention-bot list ` will return a list Youtube videos for the given .\n \n" + "Example:\n" + " * @mention-bot funny cats\n" + " * @mention-bot list funny dogs" + ) # Override default function in BotTestCase def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_keyok'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation('test_keyok'): self.verify_reply('', self.help_content) def test_single(self) -> None: - bot_response = 'Here is what I found for `funny cats` : \n'\ - 'Cats are so funny you will die laughing - ' \ - 'Funny cat compilation - [Watch now](https://www.youtube.com/watch?v=5dsGWM5XGdg)' + bot_response = ( + 'Here is what I found for `funny cats` : \n' + 'Cats are so funny you will die laughing - ' + 'Funny cat compilation - [Watch now](https://www.youtube.com/watch?v=5dsGWM5XGdg)' + ) - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_single'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation('test_single'): self.verify_reply('funny cats', bot_response) def test_invalid_key(self) -> None: bot = get_bot_message_handler(self.bot_name) bot_handler = StubBotHandler() - with self.mock_config_info({'key': 'somethinginvalid', 'number_of_results': '5', 'video_region': 'US'}), \ - self.mock_http_conversation('test_invalid_key'), \ - self.assertRaises(bot_handler.BotQuitException): + with self.mock_config_info( + {'key': 'somethinginvalid', 'number_of_results': '5', 'video_region': 'US'} + ), self.mock_http_conversation('test_invalid_key'), self.assertRaises( + bot_handler.BotQuitException + ): bot.initialize(bot_handler) def test_unknown_error(self) -> None: bot = get_bot_message_handler(self.bot_name) bot_handler = StubBotHandler() - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_unknown_error'), \ - self.assertRaises(HTTPError): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_unknown_error' + ), self.assertRaises(HTTPError): bot.initialize(bot_handler) def test_multiple(self) -> None: get_bot_message_handler(self.bot_name) StubBotHandler() - bot_response = 'Here is what I found for `marvel` : ' \ - '\n * Marvel Studios\' Avengers: Infinity War Official Trailer - [Watch now](https://www.youtube.com/watch/6ZfuNTqbHE8)' \ - '\n * Marvel Studios\' Black Panther - Official Trailer - [Watch now](https://www.youtube.com/watch/xjDjIWPwcPU)' \ - '\n * MARVEL RISING BEGINS! | The Next Generation of Marvel Heroes (EXCLUSIVE) - [Watch now](https://www.youtube.com/watch/6HTPCTtkWoA)' \ - '\n * Marvel Contest of Champions Taskmaster Spotlight - [Watch now](https://www.youtube.com/watch/-8uqxdcJ9WM)' \ - '\n * 5* Crystal Opening! SO LUCKY! - Marvel Contest Of Champions - [Watch now](https://www.youtube.com/watch/l7rrsGKJ_O4)' + bot_response = ( + 'Here is what I found for `marvel` : ' + '\n * Marvel Studios\' Avengers: Infinity War Official Trailer - [Watch now](https://www.youtube.com/watch/6ZfuNTqbHE8)' + '\n * Marvel Studios\' Black Panther - Official Trailer - [Watch now](https://www.youtube.com/watch/xjDjIWPwcPU)' + '\n * MARVEL RISING BEGINS! | The Next Generation of Marvel Heroes (EXCLUSIVE) - [Watch now](https://www.youtube.com/watch/6HTPCTtkWoA)' + '\n * Marvel Contest of Champions Taskmaster Spotlight - [Watch now](https://www.youtube.com/watch/-8uqxdcJ9WM)' + '\n * 5* Crystal Opening! SO LUCKY! - Marvel Contest Of Champions - [Watch now](https://www.youtube.com/watch/l7rrsGKJ_O4)' + ) - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_multiple'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_multiple' + ): self.verify_reply('list marvel', bot_response) def test_noresult(self) -> None: - bot_response = 'Oops ! Sorry I couldn\'t find any video for `somethingrandomwithnoresult` ' \ - ':slightly_frowning_face:' + bot_response = ( + 'Oops ! Sorry I couldn\'t find any video for `somethingrandomwithnoresult` ' + ':slightly_frowning_face:' + ) - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_noresult'): - self.verify_reply('somethingrandomwithnoresult', bot_response,) + with self.mock_config_info(self.normal_config), self.mock_http_conversation( + 'test_noresult' + ): + self.verify_reply( + 'somethingrandomwithnoresult', + bot_response, + ) def test_help(self) -> None: help_content = self.help_content - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_keyok'): + with self.mock_config_info(self.normal_config), self.mock_http_conversation('test_keyok'): self.verify_reply('help', help_content) self.verify_reply('list', help_content) self.verify_reply('help list', help_content) self.verify_reply('top', help_content) def test_connection_error(self) -> None: - with self.mock_config_info(self.normal_config), \ - patch('requests.get', side_effect=ConnectionError()), \ - patch('logging.exception'): - self.verify_reply('Wow !', 'Uh-Oh, couldn\'t process the request ' - 'right now.\nPlease again later') + with self.mock_config_info(self.normal_config), patch( + 'requests.get', side_effect=ConnectionError() + ), patch('logging.exception'): + self.verify_reply( + 'Wow !', 'Uh-Oh, couldn\'t process the request ' 'right now.\nPlease again later' + ) diff --git a/zulip_bots/zulip_bots/bots/youtube/youtube.py b/zulip_bots/zulip_bots/bots/youtube/youtube.py index 219897e..afc2370 100644 --- a/zulip_bots/zulip_bots/bots/youtube/youtube.py +++ b/zulip_bots/zulip_bots/bots/youtube/youtube.py @@ -8,22 +8,25 @@ from zulip_bots.lib import BotHandler commands_list = ('list', 'top', 'help') -class YoutubeHandler: +class YoutubeHandler: def usage(self) -> str: return ''' This plugin will allow users to search for a given search term on Youtube. Use '@mention-bot help' to get more information on the bot usage. ''' - help_content = "*Help for YouTube bot* :robot_face: : \n\n" \ - "The bot responds to messages starting with @mention-bot.\n\n" \ - "`@mention-bot ` will return top Youtube video for the given ``.\n" \ - "`@mention-bot top ` also returns the top Youtube result.\n" \ - "`@mention-bot list ` will return a list Youtube videos for the given .\n \n" \ - "Example:\n" \ - " * @mention-bot funny cats\n" \ - " * @mention-bot list funny dogs" + + help_content = ( + "*Help for YouTube bot* :robot_face: : \n\n" + "The bot responds to messages starting with @mention-bot.\n\n" + "`@mention-bot ` will return top Youtube video for the given ``.\n" + "`@mention-bot top ` also returns the top Youtube result.\n" + "`@mention-bot list ` will return a list Youtube videos for the given .\n \n" + "Example:\n" + " * @mention-bot funny cats\n" + " * @mention-bot list funny dogs" + ) def initialize(self, bot_handler: BotHandler) -> None: self.config_info = bot_handler.get_config_info('youtube') @@ -31,9 +34,10 @@ class YoutubeHandler: try: search_youtube('test', self.config_info['key'], self.config_info['video_region']) except HTTPError as e: - if (e.response.json()['error']['errors'][0]['reason'] == 'keyInvalid'): - bot_handler.quit('Invalid key.' - 'Follow the instructions in doc.md for setting API key.') + if e.response.json()['error']['errors'][0]['reason'] == 'keyInvalid': + bot_handler.quit( + 'Invalid key.' 'Follow the instructions in doc.md for setting API key.' + ) else: raise except ConnectionError: @@ -45,15 +49,12 @@ class YoutubeHandler: bot_handler.send_reply(message, self.help_content) else: cmd, query = get_command_query(message) - bot_response = get_bot_response(query, - cmd, - self.config_info) + bot_response = get_bot_response(query, cmd, self.config_info) logging.info(bot_response.format()) bot_handler.send_reply(message, bot_response) -def search_youtube(query: str, key: str, - region: str, max_results: int = 1) -> List[List[str]]: +def search_youtube(query: str, key: str, region: str, max_results: int = 1) -> List[List[str]]: videos = [] params = { @@ -63,7 +64,8 @@ def search_youtube(query: str, key: str, 'q': query, 'alt': 'json', 'type': 'video', - 'regionCode': region} # type: Dict[str, Union[str, int]] + 'regionCode': region, + } # type: Dict[str, Union[str, int]] url = 'https://www.googleapis.com/youtube/v3/search' try: @@ -77,8 +79,7 @@ def search_youtube(query: str, key: str, # matching videos, channels, and playlists. for search_result in search_response.get('items', []): if search_result['id']['kind'] == 'youtube#video': - videos.append([search_result['snippet']['title'], - search_result['id']['videoId']]) + videos.append([search_result['snippet']['title'], search_result['id']['videoId']]) return videos @@ -86,18 +87,20 @@ def get_command_query(message: Dict[str, str]) -> Tuple[Optional[str], str]: blocks = message['content'].lower().split() command = blocks[0] if command in commands_list: - query = message['content'][len(command) + 1:].lstrip() + query = message['content'][len(command) + 1 :].lstrip() return command, query else: return None, message['content'] -def get_bot_response(query: Optional[str], command: Optional[str], config_info: Dict[str, str]) -> str: +def get_bot_response( + query: Optional[str], command: Optional[str], config_info: Dict[str, str] +) -> str: key = config_info['key'] max_results = int(config_info['number_of_results']) region = config_info['video_region'] - video_list = [] # type: List[List[str]] + video_list = [] # type: List[List[str]] try: if query == '' or query is None: return YoutubeHandler.help_content @@ -111,19 +114,23 @@ def get_bot_response(query: Optional[str], command: Optional[str], config_info: return YoutubeHandler.help_content except (ConnectionError, HTTPError): - return 'Uh-Oh, couldn\'t process the request ' \ - 'right now.\nPlease again later' + return 'Uh-Oh, couldn\'t process the request ' 'right now.\nPlease again later' reply = 'Here is what I found for `' + query + '` : ' if len(video_list) == 0: - return 'Oops ! Sorry I couldn\'t find any video for `' + query + '` :slightly_frowning_face:' + return ( + 'Oops ! Sorry I couldn\'t find any video for `' + query + '` :slightly_frowning_face:' + ) elif len(video_list) == 1: - return (reply + '\n%s - [Watch now](https://www.youtube.com/watch?v=%s)' % (video_list[0][0], video_list[0][1])).strip() + return ( + reply + + '\n%s - [Watch now](https://www.youtube.com/watch?v=%s)' + % (video_list[0][0], video_list[0][1]) + ).strip() for title, id in video_list: - reply = reply + \ - '\n * %s - [Watch now](https://www.youtube.com/watch/%s)' % (title, id) + reply = reply + '\n * %s - [Watch now](https://www.youtube.com/watch/%s)' % (title, id) # Using link https://www.youtube.com/watch/ to # prevent showing multiple previews return reply diff --git a/zulip_bots/zulip_bots/custom_exceptions.py b/zulip_bots/zulip_bots/custom_exceptions.py index 7b7f92b..2b4fd7e 100644 --- a/zulip_bots/zulip_bots/custom_exceptions.py +++ b/zulip_bots/zulip_bots/custom_exceptions.py @@ -4,6 +4,7 @@ # current architecture works by lib.py importing bots, not # the other way around. + class ConfigValidationError(Exception): ''' Raise if the config data passed to a bot's validate_config() diff --git a/zulip_bots/zulip_bots/finder.py b/zulip_bots/zulip_bots/finder.py index 8886971..5f15801 100644 --- a/zulip_bots/zulip_bots/finder.py +++ b/zulip_bots/zulip_bots/finder.py @@ -7,6 +7,7 @@ from typing import Any, Optional, Text, Tuple current_dir = os.path.dirname(os.path.abspath(__file__)) + def import_module_from_source(path: Text, name: Text) -> Any: spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) @@ -16,12 +17,14 @@ def import_module_from_source(path: Text, name: Text) -> Any: loader.exec_module(module) return module + def import_module_by_name(name: Text) -> Any: try: return importlib.import_module(name) except ImportError: return None + def resolve_bot_path(name: Text) -> Optional[Tuple[Path, Text]]: if os.path.isfile(name): bot_path = Path(name) diff --git a/zulip_bots/zulip_bots/game_handler.py b/zulip_bots/zulip_bots/game_handler.py index 60351fd..265fa0d 100644 --- a/zulip_bots/zulip_bots/game_handler.py +++ b/zulip_bots/zulip_bots/game_handler.py @@ -15,6 +15,7 @@ class BadMoveException(Exception): def __str__(self) -> str: return self.message + class SamePlayerMove(Exception): def __init__(self, message: str) -> None: self.message = message @@ -22,6 +23,7 @@ class SamePlayerMove(Exception): def __str__(self) -> str: return self.message + class GameAdapter: ''' Class that serves as a template to easily @@ -41,7 +43,7 @@ class GameAdapter: rules: str, max_players: int = 2, min_players: int = 2, - supports_computer: bool = False + supports_computer: bool = False, ) -> None: self.game_name = game_name self.bot_name = bot_name @@ -94,7 +96,12 @@ class GameAdapter: `cancel game` * To see rules of this game, type `rules` -{}'''.format(self.game_name, self.get_bot_username(), self.play_with_computer_help(), self.move_help_message) +{}'''.format( + self.game_name, + self.get_bot_username(), + self.play_with_computer_help(), + self.move_help_message, + ) def help_message_single_player(self) -> str: return '''** {} Bot Help:** @@ -105,7 +112,9 @@ class GameAdapter: `quit` * To see rules of this game, type `rules` -{}'''.format(self.game_name, self.get_bot_username(), self.move_help_message) +{}'''.format( + self.game_name, self.get_bot_username(), self.move_help_message + ) def get_commands(self) -> Dict[str, str]: action = self.help_message_single_player() @@ -131,50 +140,71 @@ class GameAdapter: return 'You are already in a game. Type `quit` to leave.' def confirm_new_invitation(self, opponent: str) -> str: - return 'You\'ve sent an invitation to play ' + self.game_name + ' with @**' +\ - self.get_user_by_email(opponent)['full_name'] + '**' + return ( + 'You\'ve sent an invitation to play ' + + self.game_name + + ' with @**' + + self.get_user_by_email(opponent)['full_name'] + + '**' + ) def play_with_computer_help(self) -> str: if self.supports_computer: - return '\n* To start a game with the computer, type\n`start game with` @**{}**'.format(self.get_bot_username()) + return '\n* To start a game with the computer, type\n`start game with` @**{}**'.format( + self.get_bot_username() + ) return '' def alert_new_invitation(self, game_id: str) -> str: # Since the first player invites, the challenger is always the first player player_email = self.get_players(game_id)[0] sender_name = self.get_username_by_email(player_email) - return '**' + sender_name + ' has invited you to play a game of ' + self.game_name + '.**\n' +\ - self.get_formatted_game_object(game_id) + '\n\n' +\ - 'Type ```accept``` to accept the game invitation\n' +\ - 'Type ```decline``` to decline the game invitation.' + return ( + '**' + + sender_name + + ' has invited you to play a game of ' + + self.game_name + + '.**\n' + + self.get_formatted_game_object(game_id) + + '\n\n' + + 'Type ```accept``` to accept the game invitation\n' + + 'Type ```decline``` to decline the game invitation.' + ) def confirm_invitation_accepted(self, game_id: str) -> str: host = self.invites[game_id]['host'] - return 'Accepted invitation to play **{}** from @**{}**.'.format(self.game_name, self.get_username_by_email(host)) + return 'Accepted invitation to play **{}** from @**{}**.'.format( + self.game_name, self.get_username_by_email(host) + ) def confirm_invitation_declined(self, game_id: str) -> str: host = self.invites[game_id]['host'] - return 'Declined invitation to play **{}** from @**{}**.'.format(self.game_name, self.get_username_by_email(host)) + return 'Declined invitation to play **{}** from @**{}**.'.format( + self.game_name, self.get_username_by_email(host) + ) def send_message(self, to: str, content: str, is_private: bool, subject: str = '') -> None: - self.bot_handler.send_message(dict( - type='private' if is_private else 'stream', - to=to, - content=content, - subject=subject - )) + self.bot_handler.send_message( + dict( + type='private' if is_private else 'stream', to=to, content=content, subject=subject + ) + ) def send_reply(self, original_message: Dict[str, Any], content: str) -> None: self.bot_handler.send_reply(original_message, content) def usage(self) -> str: - return ''' + return ( + ''' Bot that allows users to play another user - or the computer in a game of ''' + self.game_name + ''' + or the computer in a game of ''' + + self.game_name + + ''' To see the entire list of commands, type @bot-name help ''' + ) def initialize(self, bot_handler: BotHandler) -> None: self.bot_handler = bot_handler @@ -190,10 +220,9 @@ class GameAdapter: message['sender_email'] = message['sender_email'].lower() if self.email not in self.user_cache.keys() and self.supports_computer: - self.add_user_to_cache({ - 'sender_email': self.email, - 'sender_full_name': self.full_name - }) + self.add_user_to_cache( + {'sender_email': self.email, 'sender_full_name': self.full_name} + ) if sender == self.email: return @@ -203,7 +232,9 @@ class GameAdapter: logging.info('Added {} to user cache'.format(sender)) if self.is_single_player: - if content.lower().startswith('start game with') or content.lower().startswith('play game'): + if content.lower().startswith('start game with') or content.lower().startswith( + 'play game' + ): self.send_reply(message, self.help_message_single_player()) return else: @@ -241,7 +272,9 @@ class GameAdapter: elif content.lower() == 'register': self.send_reply( - message, 'Hello @**{}**. Thanks for registering!'.format(message['sender_full_name'])) + message, + 'Hello @**{}**. Thanks for registering!'.format(message['sender_full_name']), + ) elif content.lower() == 'leaderboard': self.command_leaderboard(message, sender, content) @@ -252,9 +285,14 @@ class GameAdapter: elif self.is_user_in_game(sender) != '': self.parse_message(message) - elif self.move_regex.match(content) is not None or content.lower() == 'draw' or content.lower() == 'forfeit': + elif ( + self.move_regex.match(content) is not None + or content.lower() == 'draw' + or content.lower() == 'forfeit' + ): self.send_reply( - message, 'You are not in a game at the moment. Type `help` for help.') + message, 'You are not in a game at the moment. Type `help` for help.' + ) else: if self.is_single_player: self.send_reply(message, self.help_message_single_player()) @@ -272,8 +310,7 @@ class GameAdapter: def command_start_game_with(self, message: Dict[str, Any], sender: str, content: str) -> None: if not self.is_user_not_player(sender, message): - self.send_reply( - message, self.already_in_game_message()) + self.send_reply(message, self.already_in_game_message()) return users = content.replace('start game with ', '').strip().split(', ') self.create_game_lobby(message, users) @@ -285,10 +322,11 @@ class GameAdapter: return else: self.send_reply( - message, 'If you are starting a game in private messages, you must invite players. Type `help` for commands.') + message, + 'If you are starting a game in private messages, you must invite players. Type `help` for commands.', + ) if not self.is_user_not_player(sender, message): - self.send_reply( - message, self.already_in_game_message()) + self.send_reply(message, self.already_in_game_message()) return self.create_game_lobby(message) if self.is_single_player: @@ -296,18 +334,18 @@ class GameAdapter: def command_accept(self, message: Dict[str, Any], sender: str, content: str) -> None: if not self.is_user_not_player(sender, message): - self.send_reply( - message, self.already_in_game_message()) + self.send_reply(message, self.already_in_game_message()) return game_id = self.set_invite_by_user(sender, True, message) if game_id == '': - self.send_reply( - message, 'No active invites. Type `help` for commands.') + self.send_reply(message, 'No active invites. Type `help` for commands.') return if message['type'] == 'private': self.send_reply(message, self.confirm_invitation_accepted(game_id)) self.broadcast( - game_id, '@**{}** has accepted the invitation.'.format(self.get_username_by_email(sender))) + game_id, + '@**{}** has accepted the invitation.'.format(self.get_username_by_email(sender)), + ) self.start_game_if_ready(game_id) def create_game_lobby(self, message: Dict[str, Any], users: List[str] = []) -> None: @@ -318,62 +356,79 @@ class GameAdapter: users = self.verify_users(users, message=message) if len(users) + 1 < self.min_players: self.send_reply( - message, 'You must have at least {} players to play.\nGame cancelled.'.format(self.min_players)) + message, + 'You must have at least {} players to play.\nGame cancelled.'.format( + self.min_players + ), + ) return if len(users) + 1 > self.max_players: self.send_reply( - message, 'The maximum number of players for this game is {}.'.format(self.max_players)) + message, + 'The maximum number of players for this game is {}.'.format(self.max_players), + ) return game_id = self.generate_game_id() stream_subject = '###private###' if message['type'] == 'stream': stream_subject = message['subject'] - self.invites[game_id] = {'host': message['sender_email'].lower( - ), 'subject': stream_subject, 'stream': message['display_recipient']} + self.invites[game_id] = { + 'host': message['sender_email'].lower(), + 'subject': stream_subject, + 'stream': message['display_recipient'], + } if message['type'] == 'private': self.invites[game_id]['stream'] = 'games' for user in users: self.send_invite(game_id, user, message) if message['type'] == 'stream': if len(users) > 0: - self.broadcast(game_id, 'If you were invited, and you\'re here, type "@**{}** accept" to accept the invite!'.format( - self.get_bot_username()), include_private=False) + self.broadcast( + game_id, + 'If you were invited, and you\'re here, type "@**{}** accept" to accept the invite!'.format( + self.get_bot_username() + ), + include_private=False, + ) if len(users) + 1 < self.max_players: self.broadcast( - game_id, '**{}** wants to play **{}**. Type @**{}** join to play them!'.format( + game_id, + '**{}** wants to play **{}**. Type @**{}** join to play them!'.format( self.get_username_by_email(message['sender_email']), self.game_name, - self.get_bot_username()) + self.get_bot_username(), + ), ) if self.is_single_player: - self.broadcast(game_id, '**{}** is now going to play {}!'.format( - self.get_username_by_email(message['sender_email']), - self.game_name) + self.broadcast( + game_id, + '**{}** is now going to play {}!'.format( + self.get_username_by_email(message['sender_email']), self.game_name + ), ) if self.email in users: - self.broadcast(game_id, 'Wait... That\'s me!', - include_private=True) + self.broadcast(game_id, 'Wait... That\'s me!', include_private=True) if message['type'] == 'stream': self.broadcast( - game_id, '@**{}** accept'.format(self.get_bot_username()), include_private=False) - game_id = self.set_invite_by_user( - self.email, True, {'type': 'stream'}) + game_id, '@**{}** accept'.format(self.get_bot_username()), include_private=False + ) + game_id = self.set_invite_by_user(self.email, True, {'type': 'stream'}) self.start_game_if_ready(game_id) def command_decline(self, message: Dict[str, Any], sender: str, content: str) -> None: if not self.is_user_not_player(sender, message): - self.send_reply( - message, self.already_in_game_message()) + self.send_reply(message, self.already_in_game_message()) return game_id = self.set_invite_by_user(sender, False, message) if game_id == '': - self.send_reply( - message, 'No active invites. Type `help` for commands.') + self.send_reply(message, 'No active invites. Type `help` for commands.') return self.send_reply(message, self.confirm_invitation_declined(game_id)) self.broadcast( - game_id, '@**{}** has declined the invitation.'.format(self.get_username_by_email(sender))) + game_id, + '@**{}** has declined the invitation.'.format(self.get_username_by_email(sender)), + ) if len(self.get_players(game_id, parameter='')) < self.min_players: self.cancel_game(game_id) @@ -383,41 +438,41 @@ class GameAdapter: self.send_reply(message, 'You are not allowed to play games in private messages.') return if game_id == '': - self.send_reply( - message, 'You are not in a game. Type `help` for all commands.') + self.send_reply(message, 'You are not in a game. Type `help` for all commands.') sender_name = self.get_username_by_email(sender) self.cancel_game(game_id, reason='**{}** quit.'.format(sender_name)) def command_join(self, message: Dict[str, Any], sender: str, content: str) -> None: if not self.is_user_not_player(sender, message): - self.send_reply( - message, self.already_in_game_message()) + self.send_reply(message, self.already_in_game_message()) return if message['type'] == 'private': self.send_reply( - message, 'You cannot join games in private messages. Type `help` for all commands.') + message, 'You cannot join games in private messages. Type `help` for all commands.' + ) return - game_id = self.get_invite_in_subject( - message['subject'], message['display_recipient']) + game_id = self.get_invite_in_subject(message['subject'], message['display_recipient']) if game_id == '': self.send_reply( - message, 'There is not a game in this subject. Type `help` for all commands.') + message, 'There is not a game in this subject. Type `help` for all commands.' + ) return self.join_game(game_id, sender, message) def command_play(self, message: Dict[str, Any], sender: str, content: str) -> None: - game_id = self.get_invite_in_subject( - message['subject'], message['display_recipient']) + game_id = self.get_invite_in_subject(message['subject'], message['display_recipient']) if game_id == '': self.send_reply( - message, 'There is not a game in this subject. Type `help` for all commands.') + message, 'There is not a game in this subject. Type `help` for all commands.' + ) return num_players = len(self.get_players(game_id)) if num_players >= self.min_players and num_players <= self.max_players: self.start_game(game_id) else: self.send_reply( - message, 'Join {} more players to start the game'.format(self.max_players-num_players) + message, + 'Join {} more players to start the game'.format(self.max_players - num_players), ) def command_leaderboard(self, message: Dict[str, Any], sender: str, content: str) -> None: @@ -426,13 +481,11 @@ class GameAdapter: top_stats = stats[0:num] response = '**Most wins**\n\n' raw_headers = ['games_won', 'games_drawn', 'games_lost', 'total_games'] - headers = ['Player'] + \ - [key.replace('_', ' ').title() for key in raw_headers] + headers = ['Player'] + [key.replace('_', ' ').title() for key in raw_headers] response += ' | '.join(headers) response += '\n' + ' | '.join([' --- ' for header in headers]) for player, stat in top_stats: - response += '\n **{}** | '.format( - self.get_username_by_email(player)) + response += '\n **{}** | '.format(self.get_username_by_email(player)) values = [str(stat[key]) for key in raw_headers] response += ' | '.join(values) self.send_reply(message, response) @@ -445,10 +498,12 @@ class GameAdapter: players.append((user_name, u['stats'])) return sorted( players, - key=lambda player: (player[1]['games_won'], - player[1]['games_drawn'], - player[1]['total_games']), - reverse=True + key=lambda player: ( + player[1]['games_won'], + player[1]['games_drawn'], + player[1]['total_games'], + ), + reverse=True, ) def send_invite(self, game_id: str, user_email: str, message: Dict[str, Any] = {}) -> None: @@ -478,22 +533,28 @@ class GameAdapter: stream = self.invites[game_id]['stream'] if self.invites[game_id]['subject'] != '###private###': subject = self.invites[game_id]['subject'] - self.instances[game_id] = GameInstance( - self, False, subject, game_id, players, stream) - self.broadcast(game_id, 'The game has started in #{} {}'.format( - stream, self.instances[game_id].subject) + '\n' + self.get_formatted_game_object(game_id)) + self.instances[game_id] = GameInstance(self, False, subject, game_id, players, stream) + self.broadcast( + game_id, + 'The game has started in #{} {}'.format(stream, self.instances[game_id].subject) + + '\n' + + self.get_formatted_game_object(game_id), + ) del self.invites[game_id] self.instances[game_id].start() def get_formatted_game_object(self, game_id: str) -> str: object = '''> **Game `{}`** > {} -> {}/{} players'''.format(game_id, self.game_name, self.get_number_of_players(game_id), self.max_players) +> {}/{} players'''.format( + game_id, self.game_name, self.get_number_of_players(game_id), self.max_players + ) if game_id in self.instances.keys(): instance = self.instances[game_id] if not self.is_single_player: object += '\n> **[Join Game](/#narrow/stream/{}/topic/{})**'.format( - instance.stream, instance.subject) + instance.stream, instance.subject + ) return object def join_game(self, game_id: str, user_email: str, message: Dict[str, Any] = {}) -> None: @@ -503,13 +564,16 @@ class GameAdapter: return self.invites[game_id].update({user_email: 'a'}) self.broadcast( - game_id, '@**{}** has joined the game'.format(self.get_username_by_email(user_email))) + game_id, '@**{}** has joined the game'.format(self.get_username_by_email(user_email)) + ) self.start_game_if_ready(game_id) def get_players(self, game_id: str, parameter: str = 'a') -> List[str]: if game_id in self.invites.keys(): players = [] # type: List[str] - if (self.invites[game_id]['subject'] == '###private###' and 'p' in parameter) or 'p' not in parameter: + if ( + self.invites[game_id]['subject'] == '###private###' and 'p' in parameter + ) or 'p' not in parameter: players = [self.invites[game_id]['host']] for player, accepted in self.invites[game_id].items(): if player == 'host' or player == 'subject' or player == 'stream': @@ -531,7 +595,7 @@ class GameAdapter: 'type': 'instance', 'stream': instance.stream, 'subject': instance.subject, - 'players': self.get_players(game_id) + 'players': self.get_players(game_id), } if game_id in self.invites.keys(): invite = self.invites[game_id] @@ -540,7 +604,7 @@ class GameAdapter: 'type': 'invite', 'stream': invite['stream'], 'subject': invite['subject'], - 'players': self.get_players(game_id) + 'players': self.get_players(game_id), } return game_info @@ -563,33 +627,37 @@ class GameAdapter: if self.is_single_player: self.send_reply(message, self.help_message_single_player()) return - self.send_reply(message, 'Join your game using the link below!\n\n{}'.format( - self.get_formatted_game_object(game_id))) + self.send_reply( + message, + 'Join your game using the link below!\n\n{}'.format( + self.get_formatted_game_object(game_id) + ), + ) return if game['subject'] != message['subject'] or game['stream'] != message['display_recipient']: if game_id not in self.pending_subject_changes: - self.send_reply(message, 'Your current game is not in this subject. \n\ + self.send_reply( + message, + 'Your current game is not in this subject. \n\ To move subjects, send your message again, otherwise join the game using the link below.\n\n\ -{}'.format(self.get_formatted_game_object(game_id))) +{}'.format( + self.get_formatted_game_object(game_id) + ), + ) self.pending_subject_changes.append(game_id) return self.pending_subject_changes.remove(game_id) self.change_game_subject( - game_id, message['display_recipient'], message['subject'], message) - self.instances[game_id].handle_message( - message['content'], message['sender_email']) + game_id, message['display_recipient'], message['subject'], message + ) + self.instances[game_id].handle_message(message['content'], message['sender_email']) def change_game_subject( - self, - game_id: str, - stream_name: str, - subject_name: str, - message: Dict[str, Any] = {} + self, game_id: str, stream_name: str, subject_name: str, message: Dict[str, Any] = {} ) -> None: if self.get_game_instance_by_subject(stream_name, subject_name) is not None: if message != {}: - self.send_reply( - message, 'There is already a game in this subject.') + self.send_reply(message, 'There is already a game in this subject.') return if game_id in self.instances.keys(): self.instances[game_id].change_subject(stream_name, subject_name) @@ -598,7 +666,9 @@ To move subjects, send your message again, otherwise join the game using the lin invite['stream'] = stream_name invite['subject'] = stream_name - def set_invite_by_user(self, user_email: str, is_accepted: bool, message: Dict[str, Any]) -> str: + def set_invite_by_user( + self, user_email: str, is_accepted: bool, message: Dict[str, Any] + ) -> str: user_email = user_email.lower() for game, users in self.invites.items(): if user_email in users.keys(): @@ -616,12 +686,7 @@ To move subjects, send your message again, otherwise join the game using the lin user = { 'email': message['sender_email'].lower(), 'full_name': message['sender_full_name'], - 'stats': { - 'total_games': 0, - 'games_won': 0, - 'games_lost': 0, - 'games_drawn': 0 - } + 'stats': {'total_games': 0, 'games_won': 0, 'games_lost': 0, 'games_drawn': 0}, } self.user_cache.update({message['sender_email'].lower(): user}) self.put_user_cache() @@ -644,14 +709,19 @@ To move subjects, send your message again, otherwise join the game using the lin failed = False for u in users: user = u.strip().lstrip('@**').rstrip('**') - if (user == self.get_bot_username() or user == self.email) and not self.supports_computer: - self.send_reply( - message, 'You cannot play against the computer in this game.') + if ( + user == self.get_bot_username() or user == self.email + ) and not self.supports_computer: + self.send_reply(message, 'You cannot play against the computer in this game.') if '@' not in user: user_obj = self.get_user_by_name(user) if user_obj == {}: self.send_reply( - message, 'I don\'t know {}. Tell them to say @**{}** register'.format(u, self.get_bot_username())) + message, + 'I don\'t know {}. Tell them to say @**{}** register'.format( + u, self.get_bot_username() + ), + ) failed = True continue user = user_obj['email'] @@ -677,16 +747,21 @@ To move subjects, send your message again, otherwise join the game using the lin return '' def is_game_in_subject(self, subject_name: str, stream_name: str) -> bool: - return self.get_invite_in_subject(subject_name, stream_name) != '' or \ - self.get_game_instance_by_subject( - subject_name, stream_name) is not None + return ( + self.get_invite_in_subject(subject_name, stream_name) != '' + or self.get_game_instance_by_subject(subject_name, stream_name) is not None + ) def is_user_not_player(self, user_email: str, message: Dict[str, Any] = {}) -> bool: user = self.get_user_by_email(user_email) if user == {}: if message != {}: - self.send_reply(message, 'I don\'t know {}. Tell them to use @**{}** register'.format( - user_email, self.get_bot_username())) + self.send_reply( + message, + 'I don\'t know {}. Tell them to use @**{}** register'.format( + user_email, self.get_bot_username() + ), + ) return False for instance in self.instances.values(): if user_email in instance.players: @@ -716,11 +791,16 @@ To move subjects, send your message again, otherwise join the game using the lin if game_id in self.invites.keys(): if self.invites[game_id]['subject'] != '###private###': self.send_message( - self.invites[game_id]['stream'], content, False, self.invites[game_id]['subject']) + self.invites[game_id]['stream'], + content, + False, + self.invites[game_id]['subject'], + ) return True if game_id in self.instances.keys(): self.send_message( - self.instances[game_id].stream, content, False, self.instances[game_id].subject) + self.instances[game_id].stream, content, False, self.instances[game_id].subject + ) return True return False @@ -757,7 +837,15 @@ class GameInstance: or waiting states. ''' - def __init__(self, gameAdapter: GameAdapter, is_private: bool, subject: str, game_id: str, players: List[str], stream: str) -> None: + def __init__( + self, + gameAdapter: GameAdapter, + is_private: bool, + subject: str, + game_id: str, + players: List[str], + stream: str, + ) -> None: self.gameAdapter = gameAdapter self.is_private = is_private self.subject = subject @@ -785,13 +873,13 @@ class GameInstance: def get_player_text(self) -> str: player_text = '' for player in self.players: - player_text += ' @**{}**'.format( - self.gameAdapter.get_username_by_email(player)) + player_text += ' @**{}**'.format(self.gameAdapter.get_username_by_email(player)) return player_text def get_start_message(self) -> str: start_message = 'Game `{}` started.\n*Remember to start your message with* @**{}**'.format( - self.game_id, self.gameAdapter.get_bot_username()) + self.game_id, self.gameAdapter.get_bot_username() + ) if not self.is_private: player_text = '\n**Players**' player_text += self.get_player_text() @@ -810,8 +898,11 @@ class GameInstance: self.current_draw[player_email] = True else: self.current_draw = {p: False for p in self.players} - self.broadcast('**{}** has voted for a draw!\nType `draw` to accept'.format( - self.gameAdapter.get_username_by_email(player_email))) + self.broadcast( + '**{}** has voted for a draw!\nType `draw` to accept'.format( + self.gameAdapter.get_username_by_email(player_email) + ) + ) self.current_draw[player_email] = True if self.check_draw(): self.end_game('draw') @@ -822,10 +913,12 @@ class GameInstance: if self.gameAdapter.is_single_player: self.broadcast('It\'s your turn') else: - self.broadcast('It\'s **{}**\'s ({}) turn.'.format( - self.gameAdapter.get_username_by_email( - self.players[self.turn]), - self.gameAdapter.gameMessageHandler.get_player_color(self.turn))) + self.broadcast( + 'It\'s **{}**\'s ({}) turn.'.format( + self.gameAdapter.get_username_by_email(self.players[self.turn]), + self.gameAdapter.gameMessageHandler.get_player_color(self.turn), + ) + ) def broadcast(self, content: str) -> None: self.gameAdapter.broadcast(self.game_id, content) @@ -855,8 +948,14 @@ class GameInstance: self.broadcast(self.parse_current_board()) return if not is_computer: - self.current_messages.append(self.gameAdapter.gameMessageHandler.alert_move_message( - '**{}**'.format(self.gameAdapter.get_username_by_email(self.players[self.turn])), content)) + self.current_messages.append( + self.gameAdapter.gameMessageHandler.alert_move_message( + '**{}**'.format( + self.gameAdapter.get_username_by_email(self.players[self.turn]) + ), + content, + ) + ) self.current_messages.append(self.parse_current_board()) game_over = self.model.determine_game_over(self.players) if game_over: @@ -872,8 +971,14 @@ class GameInstance: def same_player_turn(self, content: str, message: str, is_computer: bool) -> None: if not is_computer: - self.current_messages.append(self.gameAdapter.gameMessageHandler.alert_move_message( - '**{}**'.format(self.gameAdapter.get_username_by_email(self.players[self.turn])), content)) + self.current_messages.append( + self.gameAdapter.gameMessageHandler.alert_move_message( + '**{}**'.format( + self.gameAdapter.get_username_by_email(self.players[self.turn]) + ), + content, + ) + ) self.current_messages.append(self.parse_current_board()) # append custom message the game wants to give for the next move self.current_messages.append(message) @@ -884,10 +989,12 @@ class GameInstance: game_over = self.players[self.turn] self.end_game(game_over) return - self.current_messages.append('It\'s **{}**\'s ({}) turn.'.format( - self.gameAdapter.get_username_by_email(self.players[self.turn]), - self.gameAdapter.gameMessageHandler.get_player_color(self.turn) - )) + self.current_messages.append( + 'It\'s **{}**\'s ({}) turn.'.format( + self.gameAdapter.get_username_by_email(self.players[self.turn]), + self.gameAdapter.gameMessageHandler.get_player_color(self.turn), + ) + ) self.broadcast_current_message() if self.players[self.turn] == self.gameAdapter.email: self.make_move('', True) @@ -899,10 +1006,12 @@ class GameInstance: if self.gameAdapter.is_single_player: self.current_messages.append('It\'s your turn.') else: - self.current_messages.append('It\'s **{}**\'s ({}) turn.'.format( - self.gameAdapter.get_username_by_email(self.players[self.turn]), - self.gameAdapter.gameMessageHandler.get_player_color(self.turn) - )) + self.current_messages.append( + 'It\'s **{}**\'s ({}) turn.'.format( + self.gameAdapter.get_username_by_email(self.players[self.turn]), + self.gameAdapter.gameMessageHandler.get_player_color(self.turn), + ) + ) self.broadcast_current_message() if self.players[self.turn] == self.gameAdapter.email: self.make_move('', True) @@ -925,8 +1034,7 @@ class GameInstance: winner_name = self.gameAdapter.get_username_by_email(winner) self.broadcast('**{}** won! :tada:'.format(winner_name)) for u in self.players: - values = {'total_games': 1, 'games_won': 0, - 'games_lost': 0, 'games_drawn': 0} + values = {'total_games': 1, 'games_won': 0, 'games_lost': 0, 'games_drawn': 0} if loser == '': if u == winner: values.update({'games_won': 1}) diff --git a/zulip_bots/zulip_bots/lib.py b/zulip_bots/zulip_bots/lib.py index ff2f4bb..ffe8662 100644 --- a/zulip_bots/zulip_bots/lib.py +++ b/zulip_bots/zulip_bots/lib.py @@ -30,6 +30,7 @@ def get_bots_directory_path() -> str: current_dir = os.path.dirname(os.path.abspath(__file__)) return os.path.join(current_dir, 'bots') + def zulip_env_vars_are_present() -> bool: # We generally require a Zulip config file, but if # the user supplies the correct environment vars, we @@ -47,6 +48,7 @@ def zulip_env_vars_are_present() -> bool: # missing, we can proceed without a config file. return True + class RateLimit: def __init__(self, message_limit: int, interval_limit: int) -> None: self.message_limit = message_limit @@ -68,12 +70,14 @@ class RateLimit: logging.error(self.error_message) sys.exit(1) + class BotIdentity: def __init__(self, name: str, email: str) -> None: self.name = name self.email = email self.mention = '@**' + name + '**' + class BotStorage(Protocol): def put(self, key: Text, value: Any) -> None: ... @@ -84,6 +88,7 @@ class BotStorage(Protocol): def contains(self, key: Text) -> bool: ... + class CachedStorage: def __init__(self, parent_storage: BotStorage, init_data: Dict[str, Any]) -> None: # CachedStorage is implemented solely for the context manager of any BotHandler. @@ -128,6 +133,7 @@ class CachedStorage: else: return self._parent_storage.contains(key) + class StateHandler: def __init__(self, client: Client) -> None: self._client = client @@ -156,6 +162,7 @@ class StateHandler: def contains(self, key: Text) -> bool: return key in self.state_ + @contextmanager def use_storage(storage: BotStorage, keys: List[Text]) -> Iterator[BotStorage]: # The context manager for StateHandler that minimizes the number of round-trips to the server. @@ -167,6 +174,7 @@ def use_storage(storage: BotStorage, keys: List[Text]) -> Iterator[BotStorage]: yield cache cache.flush() + class BotHandler(Protocol): user_id: int @@ -186,7 +194,9 @@ class BotHandler(Protocol): def send_message(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: ... - def send_reply(self, message: Dict[str, Any], response: str, widget_content: Optional[str] = None) -> Optional[Dict[str, Any]]: + def send_reply( + self, message: Dict[str, Any], response: str, widget_content: Optional[str] = None + ) -> Optional[Dict[str, Any]]: ... def update_message(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: @@ -198,6 +208,7 @@ class BotHandler(Protocol): def quit(self, message: str = "") -> None: ... + class ExternalBotHandler: def __init__( self, @@ -211,19 +222,27 @@ class ExternalBotHandler: try: user_profile = client.get_profile() except ZulipError as e: - print(''' + print( + ''' ERROR: {} Have you not started the server? Or did you mis-specify the URL? - '''.format(e)) + '''.format( + e + ) + ) sys.exit(1) if user_profile.get('result') == 'error': msg = user_profile.get('msg', 'unknown') - print(''' + print( + ''' ERROR: {} - '''.format(msg)) + '''.format( + msg + ) + ) sys.exit(1) self._rate_limit = RateLimit(20, 5) @@ -238,8 +257,10 @@ class ExternalBotHandler: self.full_name = user_profile['full_name'] self.email = user_profile['email'] except KeyError: - logging.error('Cannot fetch user profile, make sure you have set' - ' up the zuliprc file correctly.') + logging.error( + 'Cannot fetch user profile, make sure you have set' + ' up the zuliprc file correctly.' + ) sys.exit(1) @property @@ -250,9 +271,9 @@ class ExternalBotHandler: return BotIdentity(self.full_name, self.email) def react(self, message: Dict[str, Any], emoji_name: str) -> Dict[str, Any]: - return self._client.add_reaction(dict(message_id=message['id'], - emoji_name=emoji_name, - reaction_type='unicode_emoji')) + return self._client.add_reaction( + dict(message_id=message['id'], emoji_name=emoji_name, reaction_type='unicode_emoji') + ) def send_message(self, message: Dict[str, Any]) -> Dict[str, Any]: if not self._rate_limit.is_legal(): @@ -262,22 +283,28 @@ class ExternalBotHandler: print("ERROR!: " + str(resp)) return resp - def send_reply(self, message: Dict[str, Any], response: str, widget_content: Optional[str] = None) -> Dict[str, Any]: + def send_reply( + self, message: Dict[str, Any], response: str, widget_content: Optional[str] = None + ) -> Dict[str, Any]: if message['type'] == 'private': - return self.send_message(dict( - type='private', - to=[x['id'] for x in message['display_recipient']], - content=response, - widget_content=widget_content, - )) + return self.send_message( + dict( + type='private', + to=[x['id'] for x in message['display_recipient']], + content=response, + widget_content=widget_content, + ) + ) else: - return self.send_message(dict( - type='stream', - to=message['display_recipient'], - subject=message['subject'], - content=response, - widget_content=widget_content, - )) + return self.send_message( + dict( + type='stream', + to=message['display_recipient'], + subject=message['subject'], + content=response, + widget_content=widget_content, + ) + ) def update_message(self, message: Dict[str, Any]) -> Dict[str, Any]: if not self._rate_limit.is_legal(): @@ -300,7 +327,8 @@ class ExternalBotHandler: raise NoBotConfigException(bot_name) if bot_name not in self.bot_config_file: - print(''' + print( + ''' WARNING! {} does not adhere to the @@ -311,7 +339,10 @@ class ExternalBotHandler: The suggested name is {}.conf We will proceed anyway. - '''.format(self.bot_config_file, bot_name)) + '''.format( + self.bot_config_file, bot_name + ) + ) # We expect the caller to pass in None if the user does # not specify a bot_config_file. If they pass in a bogus @@ -343,8 +374,10 @@ class ExternalBotHandler: if abs_filepath.startswith(self._root_dir): return open(abs_filepath) else: - raise PermissionError("Cannot open file \"{}\". Bots may only access " - "files in their local directory.".format(abs_filepath)) + raise PermissionError( + "Cannot open file \"{}\". Bots may only access " + "files in their local directory.".format(abs_filepath) + ) def quit(self, message: str = "") -> None: sys.exit(message) @@ -361,15 +394,17 @@ def extract_query_without_mention(message: Dict[str, Any], client: BotHandler) - extended_mention_match = extended_mention_regex.match(content) if extended_mention_match: - return content[extended_mention_match.end():].lstrip() + return content[extended_mention_match.end() :].lstrip() if content.startswith(mention): - return content[len(mention):].lstrip() + return content[len(mention) :].lstrip() return None -def is_private_message_but_not_group_pm(message_dict: Dict[str, Any], current_user: BotHandler) -> bool: +def is_private_message_but_not_group_pm( + message_dict: Dict[str, Any], current_user: BotHandler +) -> bool: """ Checks whether a message dict represents a PM from another user. @@ -380,7 +415,9 @@ def is_private_message_but_not_group_pm(message_dict: Dict[str, Any], current_us if not message_dict['type'] == 'private': return False is_message_from_self = current_user.user_id == message_dict['sender_id'] - recipients = [x['email'] for x in message_dict['display_recipient'] if current_user.email != x['email']] + recipients = [ + x['email'] for x in message_dict['display_recipient'] if current_user.email != x['email'] + ] return len(recipients) == 1 and not is_message_from_self @@ -442,7 +479,9 @@ def run_message_handler_for_bot( if hasattr(message_handler, 'usage'): print(message_handler.usage()) else: - print('WARNING: {} is missing usage handler, please add one eventually'.format(bot_name)) + print( + 'WARNING: {} is missing usage handler, please add one eventually'.format(bot_name) + ) def handle_message(message: Dict[str, Any], flags: List[str]) -> None: logging.info('waiting for next message') @@ -457,15 +496,14 @@ def run_message_handler_for_bot( if is_mentioned: # message['content'] will be None when the bot's @-mention is not at the beginning. # In that case, the message shall not be handled. - message['content'] = extract_query_without_mention(message=message, client=restricted_client) + message['content'] = extract_query_without_mention( + message=message, client=restricted_client + ) if message['content'] is None: return if is_private_message or is_mentioned: - message_handler.handle_message( - message=message, - bot_handler=restricted_client - ) + message_handler.handle_message(message=message, bot_handler=restricted_client) signal.signal(signal.SIGINT, exit_gracefully) diff --git a/zulip_bots/zulip_bots/provision.py b/zulip_bots/zulip_bots/provision.py index 9848e01..ff75937 100755 --- a/zulip_bots/zulip_bots/provision.py +++ b/zulip_bots/zulip_bots/provision.py @@ -16,6 +16,7 @@ def get_bot_paths() -> Iterator[str]: paths = filter(lambda d: os.path.isdir(d), bots_subdirs) return paths + def provision_bot(path_to_bot: str, force: bool) -> None: req_path = os.path.join(path_to_bot, 'requirements.txt') if os.path.isfile(req_path): @@ -49,21 +50,24 @@ Example: ./provision.py helloworld xkcd wikipedia """ parser = argparse.ArgumentParser(usage=usage) - parser.add_argument('bots_to_provision', - metavar='bots', - nargs='*', - default=available_bots, - help='specific bots to provision (default is all)') + parser.add_argument( + 'bots_to_provision', + metavar='bots', + nargs='*', + default=available_bots, + help='specific bots to provision (default is all)', + ) - parser.add_argument('--force', - default=False, - action="store_true", - help='Continue installation despite pip errors.') + parser.add_argument( + '--force', + default=False, + action="store_true", + help='Continue installation despite pip errors.', + ) - parser.add_argument('--quiet', '-q', - action='store_true', - default=False, - help='Turn off logging output.') + parser.add_argument( + '--quiet', '-q', action='store_true', default=False, help='Turn off logging output.' + ) return parser.parse_args() @@ -77,5 +81,6 @@ def main() -> None: for bot in options.bots_to_provision: provision_bot(bot, options.force) + if __name__ == '__main__': main() diff --git a/zulip_bots/zulip_bots/request_test_lib.py b/zulip_bots/zulip_bots/request_test_lib.py index becf2dc..6d23a91 100644 --- a/zulip_bots/zulip_bots/request_test_lib.py +++ b/zulip_bots/zulip_bots/request_test_lib.py @@ -17,7 +17,10 @@ def mock_http_conversation(http_data: Dict[str, Any]) -> Any: http_data should be fixtures data formatted like the data in zulip_bots/zulip_bots/bots/giphy/fixtures/test_normal.json """ - def get_response(http_response: Dict[str, Any], http_headers: Dict[str, Any], is_raw_response: bool) -> Any: + + def get_response( + http_response: Dict[str, Any], http_headers: Dict[str, Any], is_raw_response: bool + ) -> Any: """Creates a fake `requests` Response with a desired HTTP response and response headers. """ @@ -29,7 +32,9 @@ def mock_http_conversation(http_data: Dict[str, Any]) -> Any: mock_result.status_code = http_headers.get('status', 200) return mock_result - def assert_called_with_fields(mock_result: Any, http_request: Dict[str, Any], fields: List[str], meta: Dict[str, Any]) -> None: + def assert_called_with_fields( + mock_result: Any, http_request: Dict[str, Any], fields: List[str], meta: Dict[str, Any] + ) -> None: """Calls `assert_called_with` on a mock object using an HTTP request. Uses `fields` to determine which keys to look for in HTTP request and to test; if a key is in `fields`, e.g., 'headers', it will be used in @@ -60,43 +65,30 @@ def mock_http_conversation(http_data: Dict[str, Any]) -> Any: with patch('requests.get') as mock_get: mock_get.return_value = get_response(http_response, http_headers, is_raw_response) yield - assert_called_with_fields( - mock_get, - http_request, - ['params', 'headers'], - meta - ) + assert_called_with_fields(mock_get, http_request, ['params', 'headers'], meta) elif http_method == 'PATCH': with patch('requests.patch') as mock_patch: mock_patch.return_value = get_response(http_response, http_headers, is_raw_response) yield assert_called_with_fields( - mock_patch, - http_request, - ['params', 'headers', 'json', 'data'], - meta + mock_patch, http_request, ['params', 'headers', 'json', 'data'], meta ) elif http_method == 'PUT': with patch('requests.put') as mock_post: mock_post.return_value = get_response(http_response, http_headers, is_raw_response) yield assert_called_with_fields( - mock_post, - http_request, - ['params', 'headers', 'json', 'data'], - meta + mock_post, http_request, ['params', 'headers', 'json', 'data'], meta ) else: with patch('requests.post') as mock_post: mock_post.return_value = get_response(http_response, http_headers, is_raw_response) yield assert_called_with_fields( - mock_post, - http_request, - ['params', 'headers', 'json', 'data'], - meta + mock_post, http_request, ['params', 'headers', 'json', 'data'], meta ) + @contextmanager def mock_request_exception() -> Any: def assert_mock_called(mock_result: Any) -> None: diff --git a/zulip_bots/zulip_bots/run.py b/zulip_bots/zulip_bots/run.py index cb6e3ec..0e746a2 100755 --- a/zulip_bots/zulip_bots/run.py +++ b/zulip_bots/zulip_bots/run.py @@ -16,6 +16,7 @@ from zulip_bots.provision import provision_bot current_dir = os.path.dirname(os.path.abspath(__file__)) + def parse_args() -> argparse.Namespace: usage = ''' zulip-run-bot --config-file ~/zuliprc @@ -23,29 +24,31 @@ def parse_args() -> argparse.Namespace: ''' parser = argparse.ArgumentParser(usage=usage) - parser.add_argument('bot', - action='store', - help='the name or path of an existing bot to run') + parser.add_argument('bot', action='store', help='the name or path of an existing bot to run') - parser.add_argument('--quiet', '-q', - action='store_true', - help='turn off logging output') + parser.add_argument('--quiet', '-q', action='store_true', help='turn off logging output') - parser.add_argument('--config-file', '-c', - action='store', - help='zulip configuration file (e.g. ~/Downloads/zuliprc)') + parser.add_argument( + '--config-file', + '-c', + action='store', + help='zulip configuration file (e.g. ~/Downloads/zuliprc)', + ) - parser.add_argument('--bot-config-file', '-b', - action='store', - help='third party configuration file (e.g. ~/giphy.conf') + parser.add_argument( + '--bot-config-file', + '-b', + action='store', + help='third party configuration file (e.g. ~/giphy.conf', + ) - parser.add_argument('--force', - action='store_true', - help='try running the bot even if dependencies install fails') + parser.add_argument( + '--force', + action='store_true', + help='try running the bot even if dependencies install fails', + ) - parser.add_argument('--provision', - action='store_true', - help='install dependencies for the bot') + parser.add_argument('--provision', action='store_true', help='install dependencies for the bot') args = parser.parse_args() return args @@ -71,17 +74,20 @@ def exit_gracefully_if_zulip_config_is_missing(config_file: Optional[str]) -> No if error_msg: print('\n') print(error_msg) - print(''' + print( + ''' You may need to download a config file from the Zulip app, or if you have already done that, you need to specify the file location correctly on the command line. If you don't want to use a config file, you must set these env vars: ZULIP_EMAIL, ZULIP_API_KEY, ZULIP_SITE. - ''') + ''' + ) sys.exit(1) + def exit_gracefully_if_bot_config_file_does_not_exist(bot_config_file: Optional[str]) -> None: if bot_config_file is None: # This is a common case, just so succeed quietly. (Some @@ -89,11 +95,14 @@ def exit_gracefully_if_bot_config_file_does_not_exist(bot_config_file: Optional[ return if not os.path.exists(bot_config_file): - print(''' + print( + ''' ERROR: %s does not exist. You probably just specified the wrong file location here. - ''' % (bot_config_file,)) + ''' + % (bot_config_file,) + ) sys.exit(1) @@ -115,10 +124,12 @@ def main() -> None: with open(req_path) as fp: deps_list = fp.read() - dep_err_msg = ("ERROR: The following dependencies for the {bot_name} bot are not installed:\n\n" - "{deps_list}\n" - "If you'd like us to install these dependencies, run:\n" - " zulip-run-bot {bot_name} --provision") + dep_err_msg = ( + "ERROR: The following dependencies for the {bot_name} bot are not installed:\n\n" + "{deps_list}\n" + "If you'd like us to install these dependencies, run:\n" + " zulip-run-bot {bot_name} --provision" + ) print(dep_err_msg.format(bot_name=bot_name, deps_list=deps_list)) sys.exit(1) else: @@ -148,16 +159,19 @@ def main() -> None: config_file=args.config_file, bot_config_file=args.bot_config_file, quiet=args.quiet, - bot_name=bot_name + bot_name=bot_name, ) except NoBotConfigException: - print(''' + print( + ''' ERROR: Your bot requires you to specify a third party config file with the --bot-config-file option. Exiting now. - ''') + ''' + ) sys.exit(1) + if __name__ == '__main__': main() diff --git a/zulip_bots/zulip_bots/simple_lib.py b/zulip_bots/zulip_bots/simple_lib.py index e772270..59b090f 100644 --- a/zulip_bots/zulip_bots/simple_lib.py +++ b/zulip_bots/zulip_bots/simple_lib.py @@ -10,7 +10,7 @@ class SimpleStorage: self.data = dict() def contains(self, key): - return (key in self.data) + return key in self.data def put(self, key, value): self.data[key] = value @@ -18,6 +18,7 @@ class SimpleStorage: def get(self, key): return self.data[key] + class MockMessageServer: # This class is needed for the incrementor bot, which # actually updates messages! @@ -32,7 +33,9 @@ class MockMessageServer: return message def add_reaction(self, reaction_data): - return dict(result='success', msg='', uri='https://server/messages/{}/reactions'.format(uuid4())) + return dict( + result='success', msg='', uri='https://server/messages/{}/reactions'.format(uuid4()) + ) def update(self, message): self.messages[message['message_id']] = message @@ -40,6 +43,7 @@ class MockMessageServer: def upload_file(self, file): return dict(result='success', msg='', uri='https://server/user_uploads/{}'.format(uuid4())) + class TerminalBotHandler: def __init__(self, bot_config_file, message_server): self.bot_config_file = bot_config_file @@ -58,24 +62,32 @@ class TerminalBotHandler: Mock adding an emoji reaction and print it in the terminal. """ print("""The bot reacts to message #{}: {}""".format(message["id"], emoji_name)) - return self.message_server.add_reaction(dict(message_id=message['id'], - emoji_name=emoji_name, - reaction_type='unicode_emoji')) + return self.message_server.add_reaction( + dict(message_id=message['id'], emoji_name=emoji_name, reaction_type='unicode_emoji') + ) def send_message(self, message): """ Print the message sent in the terminal and store it in a mock message server. """ if message['type'] == 'stream': - print(''' + print( + ''' stream: {} topic: {} {} - '''.format(message['to'], message['subject'], message['content'])) + '''.format( + message['to'], message['subject'], message['content'] + ) + ) else: - print(''' + print( + ''' PM response: {} - '''.format(message['content'])) + '''.format( + message['content'] + ) + ) # Note that message_server is only responsible for storing and assigning an # id to the message instead of actually displaying it. return self.message_server.send(message) @@ -84,10 +96,12 @@ class TerminalBotHandler: """ Print the reply message in the terminal and store it in a mock message server. """ - print("\nReply from the bot is printed between the dotted lines:\n-------\n{}\n-------".format(response)) - response_message = dict( - content=response + print( + "\nReply from the bot is printed between the dotted lines:\n-------\n{}\n-------".format( + response + ) ) + response_message = dict(content=response) return self.message_server.send(response_message) def update_message(self, message): @@ -96,10 +110,14 @@ class TerminalBotHandler: Throw an IndexError if the message id is invalid. """ self.message_server.update(message) - print(''' + print( + ''' update to message #{}: {} - '''.format(message['message_id'], message['content'])) + '''.format( + message['message_id'], message['content'] + ) + ) def upload_file_from_path(self, file_path): with open(file_path) as file: diff --git a/zulip_bots/zulip_bots/terminal.py b/zulip_bots/zulip_bots/terminal.py index 4678830..ec87777 100644 --- a/zulip_bots/zulip_bots/terminal.py +++ b/zulip_bots/zulip_bots/terminal.py @@ -8,6 +8,7 @@ from zulip_bots.simple_lib import MockMessageServer, TerminalBotHandler current_dir = os.path.dirname(os.path.abspath(__file__)) + def parse_args(): description = ''' This tool allows you to test a bot using the terminal (and no Zulip server). @@ -15,19 +16,22 @@ def parse_args(): Examples: %(prog)s followup ''' - parser = argparse.ArgumentParser(description=description, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument('bot', - action='store', - help='the name or path an existing bot to run') + parser = argparse.ArgumentParser( + description=description, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument('bot', action='store', help='the name or path an existing bot to run') - parser.add_argument('--bot-config-file', '-b', - action='store', - help='optional third party config file (e.g. ~/giphy.conf)') + parser.add_argument( + '--bot-config-file', + '-b', + action='store', + help='optional third party config file (e.g. ~/giphy.conf)', + ) args = parser.parse_args() return args + def main(): args = parse_args() @@ -60,20 +64,25 @@ def main(): while True: content = input('Enter your message: ') - message = message_server.send(dict( - content=content, - sender_email=sender_email, - display_recipient=sender_email, - )) + message = message_server.send( + dict( + content=content, + sender_email=sender_email, + display_recipient=sender_email, + ) + ) message_handler.handle_message( message=message, bot_handler=bot_handler, ) except KeyboardInterrupt: - print("\n\nOk, if you're happy with your terminal-based testing, try it out with a Zulip server.", - "\nYou can refer to https://zulip.com/api/running-bots#running-a-bot.") + print( + "\n\nOk, if you're happy with your terminal-based testing, try it out with a Zulip server.", + "\nYou can refer to https://zulip.com/api/running-bots#running-a-bot.", + ) sys.exit(1) + if __name__ == '__main__': main() diff --git a/zulip_bots/zulip_bots/test_file_utils.py b/zulip_bots/zulip_bots/test_file_utils.py index 81c6fc1..12d8b3a 100644 --- a/zulip_bots/zulip_bots/test_file_utils.py +++ b/zulip_bots/zulip_bots/test_file_utils.py @@ -13,6 +13,7 @@ directory structure is currently: fixtures/ ''' + def get_bot_message_handler(bot_name: str) -> Any: # message_handler is of type 'Any', since it can contain any bot's # handler class. Eventually, we want bot's handler classes to @@ -21,9 +22,11 @@ def get_bot_message_handler(bot_name: str) -> Any: lib_module = import_module('zulip_bots.bots.{bot}.{bot}'.format(bot=bot_name)) # type: Any return lib_module.handler_class() + def read_bot_fixture_data(bot_name: str, test_name: str) -> Dict[str, Any]: - base_path = os.path.realpath(os.path.join(os.path.dirname( - os.path.abspath(__file__)), 'bots', bot_name, 'fixtures')) + base_path = os.path.realpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'bots', bot_name, 'fixtures') + ) http_data_path = os.path.join(base_path, '{}.json'.format(test_name)) with open(http_data_path, encoding='utf-8') as f: content = f.read() diff --git a/zulip_bots/zulip_bots/test_lib.py b/zulip_bots/zulip_bots/test_lib.py index 9138d6a..b0985f2 100755 --- a/zulip_bots/zulip_bots/test_lib.py +++ b/zulip_bots/zulip_bots/test_lib.py @@ -27,12 +27,10 @@ class StubBotHandler: self.transcript.append(('send_message', message)) return self.message_server.send(message) - def send_reply(self, message: Dict[str, Any], response: str, - widget_content: Optional[str] = None) -> Dict[str, Any]: - response_message = dict( - content=response, - widget_content=widget_content - ) + def send_reply( + self, message: Dict[str, Any], response: str, widget_content: Optional[str] = None + ) -> Dict[str, Any]: + response_message = dict(content=response, widget_content=widget_content) self.transcript.append(('send_reply', response_message)) return self.message_server.send(response_message) @@ -59,21 +57,12 @@ class StubBotHandler: return {} def unique_reply(self) -> Dict[str, Any]: - responses = [ - message - for (method, message) - in self.transcript - if method == 'send_reply' - ] + responses = [message for (method, message) in self.transcript if method == 'send_reply'] self.ensure_unique_response(responses) return responses[0] def unique_response(self) -> Dict[str, Any]: - responses = [ - message - for (method, message) - in self.transcript - ] + responses = [message for (method, message) in self.transcript] self.ensure_unique_response(responses) return responses[0] @@ -181,4 +170,6 @@ class BotTestCase(unittest.TestCase): return mock_request_exception() def mock_config_info(self, config_info: Dict[str, str]) -> Any: - return unittest.mock.patch('zulip_bots.test_lib.StubBotHandler.get_config_info', return_value=config_info) + return unittest.mock.patch( + 'zulip_bots.test_lib.StubBotHandler.get_config_info', return_value=config_info + ) diff --git a/zulip_bots/zulip_bots/tests/test_finder.py b/zulip_bots/zulip_bots/tests/test_finder.py index 996dc0f..8b82980 100644 --- a/zulip_bots/zulip_bots/tests/test_finder.py +++ b/zulip_bots/zulip_bots/tests/test_finder.py @@ -5,7 +5,6 @@ from zulip_bots import finder class FinderTestCase(TestCase): - def test_resolve_bot_path(self) -> None: current_directory = Path(__file__).parents[1].as_posix() expected_bot_path = Path(current_directory + '/bots/helloworld/helloworld.py') diff --git a/zulip_bots/zulip_bots/tests/test_lib.py b/zulip_bots/zulip_bots/tests/test_lib.py index 382cb72..8819201 100644 --- a/zulip_bots/zulip_bots/tests/test_lib.py +++ b/zulip_bots/zulip_bots/tests/test_lib.py @@ -45,6 +45,7 @@ class FakeClient: def upload_file(self, file): pass + class FakeBotHandler: def usage(self): return ''' @@ -55,15 +56,13 @@ class FakeBotHandler: def handle_message(self, message, bot_handler): pass + class LibTest(TestCase): def test_basics(self): client = FakeClient() handler = ExternalBotHandler( - client=client, - root_dir=None, - bot_details=None, - bot_config_file=None + client=client, root_dir=None, bot_details=None, bot_config_file=None ) message = None @@ -97,9 +96,9 @@ class LibTest(TestCase): self.assertEqual(val, [1, 2, 3]) # force us to get non-cached values - client.get_storage = MagicMock(return_value=dict( - result='success', - storage=dict(non_cached_key='[5]'))) + client.get_storage = MagicMock( + return_value=dict(result='success', storage=dict(non_cached_key='[5]')) + ) val = state_handler.get('non_cached_key') client.get_storage.assert_called_with({'keys': ['non_cached_key']}) self.assertEqual(val, [5]) @@ -113,14 +112,15 @@ class LibTest(TestCase): def test_react(self): client = FakeClient() handler = ExternalBotHandler( - client = client, - root_dir=None, - bot_details=None, - bot_config_file=None + client=client, root_dir=None, bot_details=None, bot_config_file=None ) emoji_name = 'wave' message = {'id': 10} - expected = {'message_id': message['id'], 'emoji_name': 'wave', 'reaction_type': 'unicode_emoji'} + expected = { + 'message_id': message['id'], + 'emoji_name': 'wave', + 'reaction_type': 'unicode_emoji', + } client.add_reaction = MagicMock() handler.react(message, emoji_name) client.add_reaction.assert_called_once_with(dict(expected)) @@ -129,34 +129,38 @@ class LibTest(TestCase): client = FakeClient() profile = client.get_profile() handler = ExternalBotHandler( - client=client, - root_dir=None, - bot_details=None, - bot_config_file=None + client=client, root_dir=None, bot_details=None, bot_config_file=None ) to = {'id': 43} - expected = [({'type': 'private', 'display_recipient': [to]}, - {'type': 'private', 'to': [to['id']]}, None), - ({'type': 'private', 'display_recipient': [to, profile]}, - {'type': 'private', 'to': [to['id'], profile['id']]}, 'widget_content'), - ({'type': 'stream', 'display_recipient': 'Stream name', 'subject': 'Topic'}, - {'type': 'stream', 'to': 'Stream name', 'subject': 'Topic'}, 'test widget')] + expected = [ + ( + {'type': 'private', 'display_recipient': [to]}, + {'type': 'private', 'to': [to['id']]}, + None, + ), + ( + {'type': 'private', 'display_recipient': [to, profile]}, + {'type': 'private', 'to': [to['id'], profile['id']]}, + 'widget_content', + ), + ( + {'type': 'stream', 'display_recipient': 'Stream name', 'subject': 'Topic'}, + {'type': 'stream', 'to': 'Stream name', 'subject': 'Topic'}, + 'test widget', + ), + ] response_text = "Response" for test in expected: client.send_message = MagicMock() handler.send_reply(test[0], response_text, test[2]) - client.send_message.assert_called_once_with(dict( - test[1], content=response_text, widget_content=test[2])) + client.send_message.assert_called_once_with( + dict(test[1], content=response_text, widget_content=test[2]) + ) def test_content_and_full_content(self): client = FakeClient() client.get_profile() - ExternalBotHandler( - client=client, - root_dir=None, - bot_details=None, - bot_config_file=None - ) + ExternalBotHandler(client=client, root_dir=None, bot_details=None, bot_config_file=None) def test_run_message_handler_for_bot(self): with patch('zulip_bots.lib.Client', new=FakeClient) as fake_client: @@ -168,30 +172,32 @@ class LibTest(TestCase): def call_on_each_event_mock(self, callback, event_types=None, narrow=None): def test_message(message, flags): - event = {'message': message, - 'flags': flags, - 'type': 'message'} + event = {'message': message, 'flags': flags, 'type': 'message'} callback(event) # In the following test, expected_message is the dict that we expect # to be passed to the bot's handle_message function. - original_message = {'content': '@**Alice** bar', - 'type': 'stream'} - expected_message = {'type': 'stream', - 'content': 'bar', - 'full_content': '@**Alice** bar'} + original_message = {'content': '@**Alice** bar', 'type': 'stream'} + expected_message = { + 'type': 'stream', + 'content': 'bar', + 'full_content': '@**Alice** bar', + } test_message(original_message, {'mentioned'}) mock_bot_handler.handle_message.assert_called_with( - message=expected_message, - bot_handler=ANY) + message=expected_message, bot_handler=ANY + ) fake_client.call_on_each_event = call_on_each_event_mock.__get__( - fake_client, fake_client.__class__) - run_message_handler_for_bot(lib_module=mock_lib_module, - quiet=True, - config_file=None, - bot_config_file=None, - bot_name='testbot') + fake_client, fake_client.__class__ + ) + run_message_handler_for_bot( + lib_module=mock_lib_module, + quiet=True, + config_file=None, + bot_config_file=None, + bot_name='testbot', + ) def test_upload_file(self): client, handler = self._create_client_and_handler_for_file_upload() @@ -213,10 +219,7 @@ class LibTest(TestCase): def test_extract_query_without_mention(self): client = FakeClient() handler = ExternalBotHandler( - client=client, - root_dir=None, - bot_details=None, - bot_config_file=None + client=client, root_dir=None, bot_details=None, bot_config_file=None ) message = {'content': "@**Alice** Hello World"} self.assertEqual(extract_query_without_mention(message, handler), "Hello World") @@ -230,10 +233,7 @@ class LibTest(TestCase): def test_is_private_message_but_not_group_pm(self): client = FakeClient() handler = ExternalBotHandler( - client=client, - root_dir=None, - bot_details=None, - bot_config_file=None + client=client, root_dir=None, bot_details=None, bot_config_file=None ) message = {} message['display_recipient'] = 'some stream' @@ -253,9 +253,6 @@ class LibTest(TestCase): client.upload_file = MagicMock() handler = ExternalBotHandler( - client=client, - root_dir=None, - bot_details=None, - bot_config_file=None + client=client, root_dir=None, bot_details=None, bot_config_file=None ) return client, handler diff --git a/zulip_bots/zulip_bots/tests/test_run.py b/zulip_bots/zulip_bots/tests/test_run.py index 85c3d8b..316c284 100644 --- a/zulip_bots/zulip_bots/tests/test_run.py +++ b/zulip_bots/zulip_bots/tests/test_run.py @@ -18,19 +18,9 @@ class TestDefaultArguments(TestCase): @patch('sys.argv', ['zulip-run-bot', 'giphy', '--config-file', '/foo/bar/baz.conf']) @patch('zulip_bots.run.run_message_handler_for_bot') - def test_argument_parsing_with_bot_name(self, mock_run_message_handler_for_bot: mock.Mock) -> None: - with patch('zulip_bots.run.exit_gracefully_if_zulip_config_is_missing'): - zulip_bots.run.main() - - mock_run_message_handler_for_bot.assert_called_with(bot_name='giphy', - config_file='/foo/bar/baz.conf', - bot_config_file=None, - lib_module=mock.ANY, - quiet=False) - - @patch('sys.argv', ['zulip-run-bot', path_to_bot, '--config-file', '/foo/bar/baz.conf']) - @patch('zulip_bots.run.run_message_handler_for_bot') - def test_argument_parsing_with_bot_path(self, mock_run_message_handler_for_bot: mock.Mock) -> None: + def test_argument_parsing_with_bot_name( + self, mock_run_message_handler_for_bot: mock.Mock + ) -> None: with patch('zulip_bots.run.exit_gracefully_if_zulip_config_is_missing'): zulip_bots.run.main() @@ -39,25 +29,50 @@ class TestDefaultArguments(TestCase): config_file='/foo/bar/baz.conf', bot_config_file=None, lib_module=mock.ANY, - quiet=False) + quiet=False, + ) + + @patch('sys.argv', ['zulip-run-bot', path_to_bot, '--config-file', '/foo/bar/baz.conf']) + @patch('zulip_bots.run.run_message_handler_for_bot') + def test_argument_parsing_with_bot_path( + self, mock_run_message_handler_for_bot: mock.Mock + ) -> None: + with patch('zulip_bots.run.exit_gracefully_if_zulip_config_is_missing'): + zulip_bots.run.main() + + mock_run_message_handler_for_bot.assert_called_with( + bot_name='giphy', + config_file='/foo/bar/baz.conf', + bot_config_file=None, + lib_module=mock.ANY, + quiet=False, + ) def test_adding_bot_parent_dir_to_sys_path_when_bot_name_specified(self) -> None: bot_name = 'helloworld' # existing bot's name expected_bot_dir_path = Path( - os.path.dirname(zulip_bots.run.__file__), - 'bots', - bot_name + os.path.dirname(zulip_bots.run.__file__), 'bots', bot_name ).as_posix() - self._test_adding_bot_parent_dir_to_sys_path(bot_qualifier=bot_name, bot_dir_path=expected_bot_dir_path) + self._test_adding_bot_parent_dir_to_sys_path( + bot_qualifier=bot_name, bot_dir_path=expected_bot_dir_path + ) @patch('os.path.isfile', return_value=True) - def test_adding_bot_parent_dir_to_sys_path_when_bot_path_specified(self, mock_os_path_isfile: mock.Mock) -> None: + def test_adding_bot_parent_dir_to_sys_path_when_bot_path_specified( + self, mock_os_path_isfile: mock.Mock + ) -> None: bot_path = '/path/to/bot' expected_bot_dir_path = Path('/path/to').as_posix() - self._test_adding_bot_parent_dir_to_sys_path(bot_qualifier=bot_path, bot_dir_path=expected_bot_dir_path) + self._test_adding_bot_parent_dir_to_sys_path( + bot_qualifier=bot_path, bot_dir_path=expected_bot_dir_path + ) - def _test_adding_bot_parent_dir_to_sys_path(self, bot_qualifier: str, bot_dir_path: str) -> None: - with patch('sys.argv', ['zulip-run-bot', bot_qualifier, '--config-file', '/path/to/config']): + def _test_adding_bot_parent_dir_to_sys_path( + self, bot_qualifier: str, bot_dir_path: str + ) -> None: + with patch( + 'sys.argv', ['zulip-run-bot', bot_qualifier, '--config-file', '/path/to/config'] + ): with patch('zulip_bots.finder.import_module_from_source', return_value=mock.Mock()): with patch('zulip_bots.run.run_message_handler_for_bot'): with patch('zulip_bots.run.exit_gracefully_if_zulip_config_is_missing'): @@ -71,8 +86,12 @@ class TestDefaultArguments(TestCase): bot_module_name = 'bot.module.name' mock_bot_module = mock.Mock() mock_bot_module.__name__ = bot_module_name - with patch('sys.argv', ['zulip-run-bot', 'bot.module.name', '--config-file', '/path/to/config']): - with patch('importlib.import_module', return_value=mock_bot_module) as mock_import_module: + with patch( + 'sys.argv', ['zulip-run-bot', 'bot.module.name', '--config-file', '/path/to/config'] + ): + with patch( + 'importlib.import_module', return_value=mock_bot_module + ) as mock_import_module: with patch('zulip_bots.run.run_message_handler_for_bot'): with patch('zulip_bots.run.exit_gracefully_if_zulip_config_is_missing'): zulip_bots.run.main() @@ -81,12 +100,14 @@ class TestDefaultArguments(TestCase): class TestBotLib(TestCase): def test_extract_query_without_mention(self) -> None: - def test_message(name: str, message: str, expected_return: Optional[str]) -> None: mock_client = mock.MagicMock() mock_client.full_name = name mock_message = {'content': message} - self.assertEqual(expected_return, extract_query_without_mention(mock_message, mock_client)) + self.assertEqual( + expected_return, extract_query_without_mention(mock_message, mock_client) + ) + test_message("xkcd", "@**xkcd**foo", "foo") test_message("xkcd", "@**xkcd** foo", "foo") test_message("xkcd", "@**xkcd** foo bar baz", "foo bar baz") @@ -97,5 +118,6 @@ class TestBotLib(TestCase): test_message("Max Mustermann", "@**Max Mustermann** foo", "foo") test_message(r"Max (Mustermann)#(*$&12]\]", r"@**Max (Mustermann)#(*$&12]\]** foo", "foo") + if __name__ == '__main__': unittest.main() diff --git a/zulip_botserver/setup.py b/zulip_botserver/setup.py index d15aee4..63fe8a3 100644 --- a/zulip_botserver/setup.py +++ b/zulip_botserver/setup.py @@ -54,6 +54,7 @@ setuptools_info = dict( try: from setuptools import find_packages, setup + package_info.update(setuptools_info) package_info['packages'] = find_packages(exclude=['tests']) @@ -67,11 +68,13 @@ except ImportError: try: module = import_module(module_name) # type: Any if version is not None: - assert(LooseVersion(module.__version__) >= LooseVersion(version)) + assert LooseVersion(module.__version__) >= LooseVersion(version) except (ImportError, AssertionError): if version is not None: - print("{name}>={version} is not installed.".format( - name=module_name, version=version), file=sys.stderr) + print( + "{name}>={version} is not installed.".format(name=module_name, version=version), + file=sys.stderr, + ) else: print("{name} is not installed.".format(name=module_name), file=sys.stderr) sys.exit(1) diff --git a/zulip_botserver/tests/server_test_lib.py b/zulip_botserver/tests/server_test_lib.py index 4e9d5bd..30769a9 100644 --- a/zulip_botserver/tests/server_test_lib.py +++ b/zulip_botserver/tests/server_test_lib.py @@ -9,7 +9,6 @@ from zulip_botserver import server class BotServerTestCase(TestCase): - def setUp(self) -> None: server.app.testing = True self.app = server.app.test_client() @@ -31,8 +30,12 @@ class BotServerTestCase(TestCase): bots_lib_modules = server.load_lib_modules(available_bots) server.app.config["BOTS_LIB_MODULES"] = bots_lib_modules if bot_handlers is None: - bot_handlers = server.load_bot_handlers(available_bots, bots_config, third_party_bot_conf) - message_handlers = server.init_message_handlers(available_bots, bots_lib_modules, bot_handlers) + bot_handlers = server.load_bot_handlers( + available_bots, bots_config, third_party_bot_conf + ) + message_handlers = server.init_message_handlers( + available_bots, bots_lib_modules, bot_handlers + ) server.app.config["BOT_HANDLERS"] = bot_handlers server.app.config["MESSAGE_HANDLERS"] = message_handlers diff --git a/zulip_botserver/tests/test_server.py b/zulip_botserver/tests/test_server.py index 86f1e72..a9b4f0b 100644 --- a/zulip_botserver/tests/test_server.py +++ b/zulip_botserver/tests/test_server.py @@ -35,14 +35,18 @@ class BotServerTests(BotServerTestCase): 'token': 'abcd1234', } } - self.assert_bot_server_response(available_bots=available_bots, - bots_config=bots_config, - event=dict(message={'content': "@**test** test message"}, - bot_email='helloworld-bot@zulip.com', - trigger='mention', - token='abcd1234'), - expected_response="beep boop", - check_success=True) + self.assert_bot_server_response( + available_bots=available_bots, + bots_config=bots_config, + event=dict( + message={'content': "@**test** test message"}, + bot_email='helloworld-bot@zulip.com', + trigger='mention', + token='abcd1234', + ), + expected_response="beep boop", + check_success=True, + ) def test_successful_request_from_two_bots(self) -> None: available_bots = ['helloworld', 'help'] @@ -58,16 +62,20 @@ class BotServerTests(BotServerTestCase): 'key': '123456789qwertyuiop', 'site': 'http://localhost', 'token': 'abcd1234', - } + }, } - self.assert_bot_server_response(available_bots=available_bots, - event=dict(message={'content': "@**test** test message"}, - bot_email='helloworld-bot@zulip.com', - trigger='mention', - token='abcd1234'), - expected_response="beep boop", - bots_config=bots_config, - check_success=True) + self.assert_bot_server_response( + available_bots=available_bots, + event=dict( + message={'content': "@**test** test message"}, + bot_email='helloworld-bot@zulip.com', + trigger='mention', + token='abcd1234', + ), + expected_response="beep boop", + bots_config=bots_config, + check_success=True, + ) def test_request_for_unkown_bot(self) -> None: bots_config = { @@ -78,11 +86,12 @@ class BotServerTests(BotServerTestCase): 'token': 'abcd1234', }, } - self.assert_bot_server_response(available_bots=['helloworld'], - event=dict(message={'content': "test message"}, - bot_email='unknown-bot@zulip.com'), - bots_config=bots_config, - check_success=False) + self.assert_bot_server_response( + available_bots=['helloworld'], + event=dict(message={'content': "test message"}, bot_email='unknown-bot@zulip.com'), + bots_config=bots_config, + check_success=False, + ) def test_wrong_bot_token(self) -> None: available_bots = ['helloworld'] @@ -94,17 +103,23 @@ class BotServerTests(BotServerTestCase): 'token': 'abcd1234', } } - self.assert_bot_server_response(available_bots=available_bots, - bots_config=bots_config, - event=dict(message={'content': "@**test** test message"}, - bot_email='helloworld-bot@zulip.com', - trigger='mention', - token='wrongtoken'), - check_success=False) + self.assert_bot_server_response( + available_bots=available_bots, + bots_config=bots_config, + event=dict( + message={'content': "@**test** test message"}, + bot_email='helloworld-bot@zulip.com', + trigger='mention', + token='wrongtoken', + ), + check_success=False, + ) @mock.patch('logging.error') @mock.patch('zulip_bots.lib.StateHandler') - def test_wrong_bot_credentials(self, mock_StateHandler: mock.Mock, mock_LoggingError: mock.Mock) -> None: + def test_wrong_bot_credentials( + self, mock_StateHandler: mock.Mock, mock_LoggingError: mock.Mock + ) -> None: available_bots = ['nonexistent-bot'] bots_config = { 'nonexistent-bot': { @@ -117,16 +132,21 @@ class BotServerTests(BotServerTestCase): # This works, but mypy still complains: # error: No overload variant of "assertRaisesRegexp" of "TestCase" matches argument types # [def (*args: builtins.object, **kwargs: builtins.object) -> builtins.SystemExit, builtins.str] - with self.assertRaisesRegexp(SystemExit, - 'Error: Bot "nonexistent-bot" doesn\'t exist. Please make ' - 'sure you have set up the botserverrc file correctly.'): + with self.assertRaisesRegexp( + SystemExit, + 'Error: Bot "nonexistent-bot" doesn\'t exist. Please make ' + 'sure you have set up the botserverrc file correctly.', + ): self.assert_bot_server_response( available_bots=available_bots, - event=dict(message={'content': "@**test** test message"}, - bot_email='helloworld-bot@zulip.com', - trigger='mention', - token='abcd1234'), - bots_config=bots_config) + event=dict( + message={'content': "@**test** test message"}, + bot_email='helloworld-bot@zulip.com', + trigger='mention', + token='abcd1234', + ), + bots_config=bots_config, + ) @mock.patch('sys.argv', ['zulip-botserver', '--config-file', '/foo/bar/baz.conf']) def test_argument_parsing_defaults(self) -> None: @@ -163,7 +183,9 @@ class BotServerTests(BotServerTestCase): assert server.read_config_from_env_vars("giphy") == {'giphy': bots_config['giphy']} # Specified bot doesn't exist; should read the first section of the config. - assert server.read_config_from_env_vars("redefined_bot") == {'redefined_bot': bots_config['hello_world']} + assert server.read_config_from_env_vars("redefined_bot") == { + 'redefined_bot': bots_config['hello_world'] + } def test_read_config_file(self) -> None: with self.assertRaises(IOError): @@ -184,7 +206,7 @@ class BotServerTests(BotServerTestCase): 'key': 'value2', 'site': 'http://localhost', 'token': 'abcd1234', - } + }, } assert json.dumps(bot_conf1, sort_keys=True) == json.dumps(expected_config1, sort_keys=True) @@ -223,24 +245,35 @@ class BotServerTests(BotServerTestCase): assert module == helloworld # load valid file path - path = Path(root_dir, 'zulip_bots/zulip_bots/bots/{bot}/{bot}.py'.format(bot='helloworld')).as_posix() + path = Path( + root_dir, 'zulip_bots/zulip_bots/bots/{bot}/{bot}.py'.format(bot='helloworld') + ).as_posix() module = server.load_lib_modules([path])[path] assert module.__name__ == 'custom_bot_module' assert module.__file__ == path assert isinstance(module, ModuleType) # load invalid module name - with self.assertRaisesRegexp(SystemExit, - 'Error: Bot "botserver-test-case-random-bot" doesn\'t exist. ' - 'Please make sure you have set up the botserverrc file correctly.'): - module = server.load_lib_modules(['botserver-test-case-random-bot'])['botserver-test-case-random-bot'] + with self.assertRaisesRegexp( + SystemExit, + 'Error: Bot "botserver-test-case-random-bot" doesn\'t exist. ' + 'Please make sure you have set up the botserverrc file correctly.', + ): + module = server.load_lib_modules(['botserver-test-case-random-bot'])[ + 'botserver-test-case-random-bot' + ] # load invalid file path - with self.assertRaisesRegexp(SystemExit, - 'Error: Bot "{}/zulip_bots/zulip_bots/bots/helloworld.py" doesn\'t exist. ' - 'Please make sure you have set up the botserverrc file correctly.'.format(root_dir)): - path = Path(root_dir, 'zulip_bots/zulip_bots/bots/{bot}.py'.format(bot='helloworld')).as_posix() + with self.assertRaisesRegexp( + SystemExit, + 'Error: Bot "{}/zulip_bots/zulip_bots/bots/helloworld.py" doesn\'t exist. ' + 'Please make sure you have set up the botserverrc file correctly.'.format(root_dir), + ): + path = Path( + root_dir, 'zulip_bots/zulip_bots/bots/{bot}.py'.format(bot='helloworld') + ).as_posix() module = server.load_lib_modules([path])[path] + if __name__ == '__main__': unittest.main() diff --git a/zulip_botserver/zulip_botserver/input_parameters.py b/zulip_botserver/zulip_botserver/input_parameters.py index 1b7d585..04b94e1 100644 --- a/zulip_botserver/zulip_botserver/input_parameters.py +++ b/zulip_botserver/zulip_botserver/input_parameters.py @@ -10,40 +10,43 @@ def parse_args() -> argparse.Namespace: mutually_exclusive_args = parser.add_mutually_exclusive_group(required=True) # config-file or use-env-vars made mutually exclusive to prevent conflicts mutually_exclusive_args.add_argument( - '--config-file', '-c', + '--config-file', + '-c', action='store', help='Config file for the Botserver. Use your `botserverrc` for multiple bots or' - '`zuliprc` for a single bot.' + '`zuliprc` for a single bot.', ) mutually_exclusive_args.add_argument( - '--use-env-vars', '-e', + '--use-env-vars', + '-e', action='store_true', - help='Load configuration from JSON in ZULIP_BOTSERVER_CONFIG environment variable.' + help='Load configuration from JSON in ZULIP_BOTSERVER_CONFIG environment variable.', ) parser.add_argument( '--bot-config-file', action='store', default=None, help='Config file for bots. Only needed when one of ' - 'the bots you want to run requires a config file.' + 'the bots you want to run requires a config file.', ) parser.add_argument( - '--bot-name', '-b', + '--bot-name', + '-b', action='store', help='Run a single bot BOT_NAME. Use this option to run the Botserver ' - 'with a `zuliprc` config file.' + 'with a `zuliprc` config file.', ) parser.add_argument( '--hostname', action='store', default="127.0.0.1", - help='Address on which you want to run the Botserver. (default: %(default)s)' + help='Address on which you want to run the Botserver. (default: %(default)s)', ) parser.add_argument( '--port', action='store', default=5002, type=int, - help='Port on which you want to run the Botserver. (default: %(default)d)' + help='Port on which you want to run the Botserver. (default: %(default)d)', ) return parser.parse_args() diff --git a/zulip_botserver/zulip_botserver/server.py b/zulip_botserver/zulip_botserver/server.py index 3d208d0..a8a8884 100644 --- a/zulip_botserver/zulip_botserver/server.py +++ b/zulip_botserver/zulip_botserver/server.py @@ -30,12 +30,15 @@ def read_config_section(parser: configparser.ConfigParser, section: str) -> Dict } return section_info + def read_config_from_env_vars(bot_name: Optional[str] = None) -> Dict[str, Dict[str, str]]: bots_config = {} # type: Dict[str, Dict[str, str]] json_config = os.environ.get('ZULIP_BOTSERVER_CONFIG') if json_config is None: - raise OSError("Could not read environment variable 'ZULIP_BOTSERVER_CONFIG': Variable not set.") + raise OSError( + "Could not read environment variable 'ZULIP_BOTSERVER_CONFIG': Variable not set." + ) # Load JSON-formatted environment variable; use OrderedDict to # preserve ordering on Python 3.6 and below. @@ -51,26 +54,34 @@ def read_config_from_env_vars(bot_name: Optional[str] = None) -> Dict[str, Dict[ first_bot_name = list(env_config.keys())[0] bots_config[bot_name] = env_config[first_bot_name] logging.warning( - "First bot name in the config list was changed from '{}' to '{}'".format(first_bot_name, bot_name) + "First bot name in the config list was changed from '{}' to '{}'".format( + first_bot_name, bot_name + ) ) else: bots_config = dict(env_config) return bots_config -def read_config_file(config_file_path: str, bot_name: Optional[str] = None) -> Dict[str, Dict[str, str]]: + +def read_config_file( + config_file_path: str, bot_name: Optional[str] = None +) -> Dict[str, Dict[str, str]]: parser = parse_config_file(config_file_path) bots_config = {} # type: Dict[str, Dict[str, str]] if bot_name is None: - bots_config = {section: read_config_section(parser, section) - for section in parser.sections()} + bots_config = { + section: read_config_section(parser, section) for section in parser.sections() + } return bots_config logging.warning("Single bot mode is enabled") if len(parser.sections()) == 0: - sys.exit("Error: Your Botserver config file `{0}` does not contain any sections!\n" - "You need to write the name of the bot you want to run in the " - "section header of `{0}`.".format(config_file_path)) + sys.exit( + "Error: Your Botserver config file `{0}` does not contain any sections!\n" + "You need to write the name of the bot you want to run in the " + "section header of `{0}`.".format(config_file_path) + ) if bot_name in parser.sections(): bot_section = bot_name @@ -80,7 +91,9 @@ def read_config_file(config_file_path: str, bot_name: Optional[str] = None) -> D bot_section = parser.sections()[0] bots_config[bot_name] = read_config_section(parser, bot_section) logging.warning( - "First bot name in the config list was changed from '{}' to '{}'".format(bot_section, bot_name) + "First bot name in the config list was changed from '{}' to '{}'".format( + bot_section, bot_name + ) ) ignored_sections = parser.sections()[1:] @@ -98,6 +111,7 @@ def parse_config_file(config_file_path: str) -> configparser.ConfigParser: parser.read(config_file_path) return parser + # TODO: Could we use the function from the bots library for this instead? def load_module_from_file(file_path: str) -> ModuleType: # Wrapper around importutil; see https://stackoverflow.com/a/67692/3909240. @@ -107,6 +121,7 @@ def load_module_from_file(file_path: str) -> ModuleType: spec.loader.exec_module(lib_module) return lib_module + def load_lib_modules(available_bots: List[str]) -> Dict[str, Any]: bots_lib_module = {} for bot in available_bots: @@ -118,10 +133,14 @@ def load_lib_modules(available_bots: List[str]) -> Dict[str, Any]: lib_module = import_module(module_name) bots_lib_module[bot] = lib_module except ImportError: - error_message = ("Error: Bot \"{}\" doesn't exist. Please make sure " - "you have set up the botserverrc file correctly.\n".format(bot)) + error_message = ( + "Error: Bot \"{}\" doesn't exist. Please make sure " + "you have set up the botserverrc file correctly.\n".format(bot) + ) if bot == "api": - error_message += "Did you forget to specify the bot you want to run with -b ?" + error_message += ( + "Did you forget to specify the bot you want to run with -b ?" + ) sys.exit(error_message) return bots_lib_module @@ -133,15 +152,14 @@ def load_bot_handlers( ) -> Dict[str, lib.ExternalBotHandler]: bot_handlers = {} for bot in available_bots: - client = Client(email=bots_config[bot]["email"], - api_key=bots_config[bot]["key"], - site=bots_config[bot]["site"]) + client = Client( + email=bots_config[bot]["email"], + api_key=bots_config[bot]["key"], + site=bots_config[bot]["site"], + ) bot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'bots', bot) bot_handler = lib.ExternalBotHandler( - client, - bot_dir, - bot_details={}, - bot_config_parser=third_party_bot_conf + client, bot_dir, bot_details={}, bot_config_parser=third_party_bot_conf ) bot_handlers[bot] = bot_handler @@ -176,13 +194,17 @@ def handle_bot() -> str: bot_config = config break else: - raise BadRequest("Cannot find a bot with email {} in the Botserver " - "configuration file. Do the emails in your botserverrc " - "match the bot emails on the server?".format(event['bot_email'])) + raise BadRequest( + "Cannot find a bot with email {} in the Botserver " + "configuration file. Do the emails in your botserverrc " + "match the bot emails on the server?".format(event['bot_email']) + ) if bot_config['token'] != event['token']: - raise Unauthorized("Request token does not match token found for bot {} in the " - "Botserver configuration file. Do the outgoing webhooks in " - "Zulip point to the right Botserver?".format(event['bot_email'])) + raise Unauthorized( + "Request token does not match token found for bot {} in the " + "Botserver configuration file. Do the outgoing webhooks in " + "Zulip point to the right Botserver?".format(event['bot_email']) + ) app.config.get("BOTS_LIB_MODULES", {})[bot] bot_handler = app.config.get("BOT_HANDLERS", {})[bot] message_handler = app.config.get("MESSAGE_HANDLERS", {})[bot] @@ -213,17 +235,24 @@ def main() -> None: try: bots_config = read_config_file(options.config_file, options.bot_name) except MissingSectionHeaderError: - sys.exit("Error: Your Botserver config file `{0}` contains an empty section header!\n" - "You need to write the names of the bots you want to run in the " - "section headers of `{0}`.".format(options.config_file)) + sys.exit( + "Error: Your Botserver config file `{0}` contains an empty section header!\n" + "You need to write the names of the bots you want to run in the " + "section headers of `{0}`.".format(options.config_file) + ) except NoOptionError as e: - sys.exit("Error: Your Botserver config file `{0}` has a missing option `{1}` in section `{2}`!\n" - "You need to add option `{1}` with appropriate value in section `{2}` of `{0}`" - .format(options.config_file, e.option, e.section)) + sys.exit( + "Error: Your Botserver config file `{0}` has a missing option `{1}` in section `{2}`!\n" + "You need to add option `{1}` with appropriate value in section `{2}` of `{0}`".format( + options.config_file, e.option, e.section + ) + ) available_bots = list(bots_config.keys()) bots_lib_modules = load_lib_modules(available_bots) - third_party_bot_conf = parse_config_file(options.bot_config_file) if options.bot_config_file is not None else None + third_party_bot_conf = ( + parse_config_file(options.bot_config_file) if options.bot_config_file is not None else None + ) bot_handlers = load_bot_handlers(available_bots, bots_config, third_party_bot_conf) message_handlers = init_message_handlers(available_bots, bots_lib_modules, bot_handlers) app.config["BOTS_LIB_MODULES"] = bots_lib_modules @@ -231,5 +260,6 @@ def main() -> None: app.config["MESSAGE_HANDLERS"] = message_handlers app.run(host=options.hostname, port=int(options.port), debug=True) + if __name__ == '__main__': main()