diff --git a/tools/custom_check.py b/tools/custom_check.py index e8344198c6..dea7edc43d 100644 --- a/tools/custom_check.py +++ b/tools/custom_check.py @@ -1,4 +1,5 @@ from typing import List + from zulint.custom_rules import RuleList MYPY = False @@ -7,107 +8,117 @@ 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, ) bash_rules = RuleList( - langs=['sh'], + 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], ], ) json_rules = RuleList( - langs=['json'], + langs=["json"], # Here, we don't check tab-based whitespace, because the tab-based # whitespace rule flags a lot of third-party JSON fixtures # under zerver/webhooks that we want preserved verbatim. So @@ -115,20 +126,27 @@ # 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 = { @@ -136,19 +154,21 @@ } markdown_rules = RuleList( - langs=['md'], + langs=["md"], 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, ) txt_rules = RuleList( - langs=['txt'], + langs=["txt"], rules=whitespace_rules, ) diff --git a/tools/deploy b/tools/deploy index 4ba8e527a2..711d73db8e 100755 --- a/tools/deploy +++ b/tools/deploy @@ -1,236 +1,268 @@ #!/usr/bin/env python3 -from typing import Any, List, Dict, Callable - +import argparse import os import sys -import argparse -import zipfile import textwrap -import requests import urllib.parse +import zipfile +from typing import Any, Callable, Dict, List + +import requests from requests import Response -red = '\033[91m' # type: str -green = '\033[92m' # type: str -end_format = '\033[0m' # type: str -bold = '\033[1m' # type: str +red = "\033[91m" # type: str +green = "\033[92m" # type: str +end_format = "\033[0m" # type: str +bold = "\033[1m" # type: str + +bots_dir = ".bots" # type: str -bots_dir = '.bots' # type: str def pack(options: argparse.Namespace) -> None: # Basic sanity checks for input. if not options.path: - print('tools/deploy: Path to bot folder not specified.') + print("tools/deploy: Path to bot folder not specified.") sys.exit(1) if not options.config: - print('tools/deploy: Path to zuliprc not specified.') + print("tools/deploy: Path to zuliprc not specified.") sys.exit(1) if not options.main: - print('tools/deploy: No main bot file specified.') + print("tools/deploy: No main bot file specified.") sys.exit(1) if not os.path.isfile(options.config): - print('pack: Config file not found at path: {}.'.format(options.config)) + print(f"pack: Config file not found at path: {options.config}.") sys.exit(1) if not os.path.isdir(options.path): - print('pack: Bot folder not found at path: {}.'.format(options.path)) + print(f"pack: Bot folder not found at path: {options.path}.") sys.exit(1) main_path = os.path.join(options.path, options.main) if not os.path.isfile(main_path): - print('pack: Bot main file not found at path: {}.'.format(main_path)) + print(f"pack: Bot main file not found at path: {main_path}.") sys.exit(1) # Main logic for packing the bot. if not os.path.exists(bots_dir): os.makedirs(bots_dir) zip_file_path = os.path.join(bots_dir, options.botname + ".zip") - zip_file = zipfile.ZipFile(zip_file_path, 'w', zipfile.ZIP_DEFLATED) + zip_file = zipfile.ZipFile(zip_file_path, "w", zipfile.ZIP_DEFLATED) # Pack the complete bot folder for root, dirs, files in os.walk(options.path): for file in files: file_path = os.path.join(root, file) zip_file.write(file_path, os.path.relpath(file_path, options.path)) # Pack the zuliprc - zip_file.write(options.config, '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)) - zip_file.writestr('config.ini', bot_config) + """.format( + options.main + ) + ) + zip_file.writestr("config.ini", bot_config) zip_file.close() - print('pack: Created zip file at: {}.'.format(zip_file_path)) + print(f"pack: Created zip file at: {zip_file_path}.") + def check_common_options(options: argparse.Namespace) -> None: if not options.server: - print('tools/deploy: URL to Botfarm server not specified.') + print("tools/deploy: URL to Botfarm server not specified.") sys.exit(1) if not options.token: - print('tools/deploy: Botfarm deploy token not specified.') + 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(f"{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': + if response_data["status"] == "success": success_handler(response_data) return True - elif response_data['status'] == 'error': - print('{}: {}'.format(operation, response_data['message'])) + elif response_data["status"] == "error": + print("{}: {}".format(operation, response_data["message"])) return False else: - print('{}: Unexpected success response format'.format(operation)) + print(f"{operation}: Unexpected success response format") return False if response.status_code == requests.codes.unauthorized: - print('{}: Authentication error with the server. Aborting.'.format(operation)) + print(f"{operation}: Authentication error with the server. Aborting.") else: - print('{}: Error {}. Aborting.'.format(operation, response.status_code)) + print(f"{operation}: Error {response.status_code}. Aborting.") return False + def upload(options: argparse.Namespace) -> None: check_common_options(options) - file_path = os.path.join(bots_dir, options.botname + '.zip') + file_path = os.path.join(bots_dir, options.botname + ".zip") if not os.path.exists(file_path): - print('upload: Could not find bot package at {}.'.format(file_path)) + print(f"upload: Could not find bot package at {file_path}.") sys.exit(1) - files = {'file': open(file_path, 'rb')} - headers = {'key': options.token} - url = urllib.parse.urljoin(options.server, 'bots/upload') + files = {"file": open(file_path, "rb")} + 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') + file_path = os.path.join(bots_dir, options.botname + ".zip") if os.path.exists(file_path): os.remove(file_path) - print('clean: Removed {}.'.format(file_path)) + print(f"clean: Removed {file_path}.") else: - print('clean: File \'{}\' not found.'.format(file_path)) + print(f"clean: File '{file_path}' not found.") + 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} + 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} + 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} + 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} + headers = {"key": options.token} if options.lines: lines = options.lines else: lines = None - payload = {'name': options.botname, 'lines': lines} - url = urllib.parse.urljoin(options.server, 'bots/logs/' + options.botname) + payload = {"name": options.botname, "lines": lines} + url = urllib.parse.urljoin(options.server, "bots/logs/" + options.botname) response = requests.get(url, json=payload, headers=headers) - result = handle_common_response(response, 'log', lambda r: print(r['logs']['content'])) + result = handle_common_response(response, "log", lambda r: print(r["logs"]["content"])) 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} + 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} + headers = {"key": options.token} if options.format: pretty_print = True else: pretty_print = False - url = urllib.parse.urljoin(options.server, 'bots/list') + 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) else: for bot in bots: - print('{}\t{}\t{}\t{}'.format(bot['name'], bot['status'], bot['email'], bot['site'])) + 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') + print("ls: No bots found on the botfarm") else: - print('ls: There are the following bots on the botfarm:') + print("ls: There are the following bots on the botfarm:") name_col_len, status_col_len, email_col_len, site_col_len = 25, 15, 35, 35 - row_format = '{0} {1} {2} {3}' + row_format = "{0} {1} {2} {3}" header = row_format.format( - 'NAME'.rjust(name_col_len), - 'STATUS'.rjust(status_col_len), - 'EMAIL'.rjust(email_col_len), - 'SITE'.rjust(site_col_len), + "NAME".rjust(name_col_len), + "STATUS".rjust(status_col_len), + "EMAIL".rjust(email_col_len), + "SITE".rjust(site_col_len), ) header_bottom = row_format.format( - '-' * name_col_len, - '-' * status_col_len, - '-' * email_col_len, - '-' * site_col_len, + "-" * name_col_len, + "-" * status_col_len, + "-" * email_col_len, + "-" * site_col_len, ) print(header) print(header_bottom) for bot in bots: row = row_format.format( - bot['name'].rjust(name_col_len), - bot['status'].rjust(status_col_len), - bot['email'].rjust(email_col_len), - bot['site'].rjust(site_col_len), + bot["name"].rjust(name_col_len), + bot["status"].rjust(status_col_len), + bot["email"].rjust(email_col_len), + bot["site"].rjust(site_col_len), ) print(row) + def main() -> None: usage = """tools/deploy [options] @@ -265,48 +297,52 @@ 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("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" + ) options = parser.parse_args() if not options.command: - print('tools/deploy: No command specified.') + print("tools/deploy: No command specified.") sys.exit(1) - if not options.botname and options.command not in ['ls']: - print('tools/deploy: No bot name specified. Please specify a name like \'my-custom-bot\'') + if not options.botname and options.command not in ["ls"]: + print("tools/deploy: No bot name specified. Please specify a name like 'my-custom-bot'") sys.exit(1) commands = { - 'pack': pack, - 'upload': upload, - 'clean': clean, - 'prepare': prepare, - 'process': process, - 'start': start, - 'stop': stop, - 'log': log, - 'delete': delete, - 'ls': list_bots, + "pack": pack, + "upload": upload, + "clean": clean, + "prepare": prepare, + "process": process, + "start": start, + "stop": stop, + "log": log, + "delete": delete, + "ls": list_bots, } if options.command in commands: commands[options.command](options) else: - print('tools/deploy: No command \'{}\' found.'.format(options.command)) -if __name__ == '__main__': + print(f"tools/deploy: No command '{options.command}' found.") + + +if __name__ == "__main__": main() diff --git a/tools/gitlint-rules.py b/tools/gitlint-rules.py index 322f96f02e..709372bdfe 100644 --- a/tools/gitlint-rules.py +++ b/tools/gitlint-rules.py @@ -11,88 +11,257 @@ # 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']: - return 'display' + if key in ["displays", "displaying", "displayed"]: + return "display" lower = 0 upper = len(words) - 1 @@ -114,31 +283,36 @@ 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 = [] # Ignore the section tag (ie `
: .`) - words = line.split(': ', 1)[-1].split() + words = line.split(": ", 1)[-1].split() first_word = words[0].lower() 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 3deda28552..56c096c2b3 100755 --- a/tools/lint +++ b/tools/lint @@ -1,17 +1,19 @@ #! /usr/bin/env python3 import argparse +import re import sys -from zulint.command import add_default_linter_arguments, LinterConfig +from zulint.command import LinterConfig, add_default_linter_arguments -from custom_check import python_rules, non_py_rules +from custom_check import non_py_rules, python_rules EXCLUDED_FILES = [ # This is an external file that doesn't comply with our codestyle - 'zulip/integrations/perforce/git_p4.py', + "zulip/integrations/perforce/git_p4.py", ] + def run() -> None: parser = argparse.ArgumentParser() add_default_linter_arguments(parser) @@ -19,15 +21,39 @@ 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.external_linter( + "isort", + ["isort"], + ["py"], + description="Sorts Python import statements", + check_arg=["--check-only", "--diff"], + ) + linter_config.external_linter( + "black", + ["black"], + ["py"], + description="Reformats Python code", + check_arg=["--check"], + suppress_line=lambda line: line == "All done! ✨ 🍰 ✨\n" + or re.fullmatch(r"\d+ files? would be left unchanged\.\n", line) is not None, + ) @linter_config.lint def custom_py() -> int: @@ -45,5 +71,6 @@ def run() -> None: linter_config.do_lint() -if __name__ == '__main__': + +if __name__ == "__main__": run() diff --git a/tools/provision b/tools/provision index 7dd3ea0a3d..0ca5436ad6 100755 --- a/tools/provision +++ b/tools/provision @@ -1,19 +1,20 @@ #!/usr/bin/env python3 -import os -import sys import argparse -import subprocess import glob +import os +import subprocess +import sys CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) -ZULIP_BOTS_DIR = os.path.join(CURRENT_DIR, '..', 'zulip_bots') +ZULIP_BOTS_DIR = os.path.join(CURRENT_DIR, "..", "zulip_bots") sys.path.append(ZULIP_BOTS_DIR) -red = '\033[91m' -green = '\033[92m' -end_format = '\033[0m' -bold = '\033[1m' +red = "\033[91m" +green = "\033[92m" +end_format = "\033[0m" +bold = "\033[1m" + def main(): usage = """./tools/provision @@ -21,76 +22,99 @@ def main(): 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) + 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 + ) # The output has the format "Python 1.2.3" - py_version_list = py_version_output.split()[1].split('.') + 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]) + venv_name = f"zulip-api-py{py_version[0]}-venv" 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) if not os.path.isdir(venv_dir): try: - return_code = subprocess.call([options.python_interpreter, '-m', 'venv', venv_dir]) + 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.") - if os.path.isdir(os.path.join(venv_dir, 'Scripts')): + if os.path.isdir(os.path.join(venv_dir, "Scripts")): # POSIX compatibility layer and Linux environment emulation for Windows # venv uses /Scripts instead of /bin on Windows cmd and Power Shell. # Read https://docs.python.org/3/library/venv.html - venv_exec_dir = 'Scripts' + venv_exec_dir = "Scripts" else: - venv_exec_dir = 'bin' + venv_exec_dir = "bin" # On OS X, ensure we use the virtualenv version of the python binary for # future subprocesses instead of the version that this script was launched with. See # https://stackoverflow.com/questions/26323852/whats-the-meaning-of-pyvenv-launcher-environment-variable - if '__PYVENV_LAUNCHER__' in os.environ: - del os.environ['__PYVENV_LAUNCHER__'] + if "__PYVENV_LAUNCHER__" in os.environ: + del os.environ["__PYVENV_LAUNCHER__"] # In order to install all required packages for the venv, `pip` needs to be executed by # the venv's Python interpreter. `--prefix venv_dir` ensures that all modules are installed # in the right place. def install_dependencies(requirements_filename): - pip_path = os.path.join(venv_dir, venv_exec_dir, 'pip') + 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))) - - install_dependencies('requirements.txt') + 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) + ) + ) + + install_dependencies("requirements.txt") # Install all requirements for all bots. get_bot_paths() # has requirements that must be satisfied prior to calling @@ -103,18 +127,15 @@ the Python version this command is executed with.""" relative_path = os.path.join(*path_split) install_dependencies(relative_path) - print(green + 'Success!' + end_format) + 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, '/') - print('\nRun the following to enter into the virtualenv:\n') - print(bold + ' source ' + activate_command + end_format + "\n") + activate_command = activate_command.replace(os.sep, "/") + print("\nRun the following to enter into the virtualenv:\n") + print(bold + " source " + activate_command + end_format + "\n") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tools/release-packages b/tools/release-packages index 4f13d84528..d58f3aa5db 100755 --- a/tools/release-packages +++ b/tools/release-packages @@ -1,18 +1,19 @@ #!/usr/bin/env python3 -from contextlib import contextmanager -import os import argparse import glob +import os import shutil import tempfile +from contextlib import contextmanager import crayons -import twine.commands.upload import setuptools.sandbox +import twine.commands.upload REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + @contextmanager def cd(newdir): prevdir = os.getcwd() @@ -22,8 +23,9 @@ 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( + message = "Generating {dist_type} for {package_name}.".format( dist_type=dist_type, package_name=package_name, ) @@ -33,40 +35,38 @@ def _generate_dist(dist_type, setup_file, package_name, setup_args): with cd(setup_dir): setuptools.sandbox.run_setup(setup_file, setup_args) - message = '{dist_type} for {package_name} generated under {dir}.\n'.format( + message = "{dist_type} for {package_name} generated under {dir}.\n".format( dist_type=dist_type, package_name=package_name, dir=setup_dir, ) 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:' + message = "Uploading distributions under the following directories:" print(crayons.green(message, bold=True)) for dist_dir in 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)) - ) + 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, f"{os.path.basename(package_dir)}.egg-info") def _rm_if_it_exists(directory): if os.path.isdir(directory): - print(crayons.green('Removing {}/*'.format(directory), bold=True)) + print(crayons.green(f"Removing {directory}/*", bold=True)) shutil.rmtree(directory) _rm_if_it_exists(build_dir) @@ -74,13 +74,14 @@ 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: + with os.fdopen(fh, "w") as new_file, open(fp) as old_file: for line in old_file: if line.startswith(variable): if isinstance(value, bool): - template = '{variable} = {value}\n' + template = "{variable} = {value}\n" else: template = '{variable} = "{value}"\n' new_file.write(template.format(variable=variable, value=value)) @@ -90,22 +91,22 @@ 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 = f"Set {variable} in {fp} to {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') - dev = os.path.join(zulip_repo_dir, 'requirements', 'dev.txt') + common = os.path.join(zulip_repo_dir, "requirements", "common.in") + prod = os.path.join(zulip_repo_dir, "requirements", "prod.txt") + dev = os.path.join(zulip_repo_dir, "requirements", "dev.txt") def _edit_reqs_file(reqs, zulip_bots_line, zulip_line): fh, temp_abs_path = tempfile.mkstemp() - with os.fdopen(fh, 'w') as new_file, open(reqs) as old_file: + with os.fdopen(fh, "w") as new_file, open(reqs) as old_file: for line in old_file: - if 'python-zulip-api' in line and 'zulip==' in line: + if "python-zulip-api" in line and "zulip==" in line: new_file.write(zulip_line) - elif 'python-zulip-api' in line and 'zulip_bots' in line: + elif "python-zulip-api" in line and "zulip_bots" in line: new_file.write(zulip_bots_line) else: new_file.write(line) @@ -113,28 +114,27 @@ def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag): os.remove(reqs) shutil.move(temp_abs_path, reqs) - 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) + 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) _edit_reqs_file(prod, zulip_bots_line, zulip_line) _edit_reqs_file(dev, zulip_bots_line, zulip_line) - editable_zulip = '-e "{}"\n'.format(url_zulip.rstrip()) - editable_zulip_bots = '-e "{}"\n'.format(url_zulip_bots.rstrip()) + editable_zulip = f'-e "{url_zulip.rstrip()}"\n' + editable_zulip_bots = f'-e "{url_zulip_bots.rstrip()}"\n' _edit_reqs_file( common, - editable_zulip_bots.format(tag=hash_or_tag, name='zulip_bots', version=version), - editable_zulip.format(tag=hash_or_tag, name='zulip', version=version), + editable_zulip_bots.format(tag=hash_or_tag, name="zulip_bots", version=version), + editable_zulip.format(tag=hash_or_tag, name="zulip", version=version), ) - message = 'Updated zulip API package requirements in the main repo.' + 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,37 +176,48 @@ 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') + 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') - parser_main_repo.add_argument('--hash', metavar='COMMIT_HASH') + parser_main_repo.add_argument("repo", metavar="PATH_TO_ZULIP_DIR") + parser_main_repo.add_argument("version", metavar="version number of the packages") + parser_main_repo.add_argument("--hash", metavar="COMMIT_HASH") return parser.parse_args() + def main(): options = parse_args() - glob_pattern = os.path.join(REPO_DIR, '*', 'setup.py') + glob_pattern = os.path.join(REPO_DIR, "*", "setup.py") setup_py_files = glob.glob(glob_pattern) if options.cleanup: @@ -219,31 +230,30 @@ def main(): for package_dir in package_dirs: cleanup(package_dir) - zulip_init = os.path.join(REPO_DIR, 'zulip', 'zulip', '__init__.py') - set_variable(zulip_init, '__version__', options.build) - bots_setup = os.path.join(REPO_DIR, 'zulip_bots', 'setup.py') - set_variable(bots_setup, 'ZULIP_BOTS_VERSION', options.build) - set_variable(bots_setup, 'IS_PYPA_PACKAGE', True) - botserver_setup = os.path.join(REPO_DIR, 'zulip_botserver', 'setup.py') - set_variable(botserver_setup, 'ZULIP_BOTSERVER_VERSION', options.build) + zulip_init = os.path.join(REPO_DIR, "zulip", "zulip", "__init__.py") + set_variable(zulip_init, "__version__", options.build) + bots_setup = os.path.join(REPO_DIR, "zulip_bots", "setup.py") + set_variable(bots_setup, "ZULIP_BOTS_VERSION", options.build) + set_variable(bots_setup, "IS_PYPA_PACKAGE", True) + botserver_setup = os.path.join(REPO_DIR, "zulip_botserver", "setup.py") + set_variable(botserver_setup, "ZULIP_BOTSERVER_VERSION", options.build) for setup_file in setup_py_files: package_name = os.path.basename(os.path.dirname(setup_file)) generate_bdist_wheel(setup_file, package_name) - set_variable(bots_setup, 'IS_PYPA_PACKAGE', False) + set_variable(bots_setup, "IS_PYPA_PACKAGE", False) if options.release: - dist_dirs = glob.glob(os.path.join(REPO_DIR, '*', 'dist', '*')) + dist_dirs = glob.glob(os.path.join(REPO_DIR, "*", "dist", "*")) twine_upload(dist_dirs) - if options.subcommand == 'update-main-repo': + 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__': +if __name__ == "__main__": main() diff --git a/tools/review b/tools/review index 8ac489d1a4..cb28232eac 100755 --- a/tools/review +++ b/tools/review @@ -3,57 +3,65 @@ import subprocess import sys + def exit(message: str) -> None: - print('PROBLEM!') + print("PROBLEM!") print(message) sys.exit(1) + def run(command: str) -> None: - print('\n>>> ' + command) + print("\n>>> " + command) subprocess.check_call(command.split()) + def check_output(command: str) -> str: - return subprocess.check_output(command.split()).decode('ascii') + return subprocess.check_output(command.split()).decode("ascii") + def get_git_branch() -> str: - command = 'git rev-parse --abbrev-ref HEAD' + command = "git rev-parse --abbrev-ref HEAD" output = check_output(command) return output.strip() + def check_git_pristine() -> None: - command = 'git status --porcelain' + command = "git status --porcelain" output = check_output(command) if output.strip(): - exit('Git is not pristine:\n' + output) + exit("Git is not pristine:\n" + output) + def ensure_on_clean_master() -> None: branch = get_git_branch() - if branch != 'master': - exit('You are still on a feature branch: %s' % (branch,)) + if branch != "master": + exit(f"You are still on a feature branch: {branch}") check_git_pristine() - run('git fetch upstream master') - run('git rebase upstream/master') + 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,)) - run('git rebase upstream/master') - run('git log upstream/master.. --oneline') - run('git diff upstream/master.. --name-status') + run("git fetch upstream pull/%d/head" % (pull_id,)) + run(f"git checkout -B review-{pull_id} FETCH_HEAD") + run("git rebase upstream/master") + run("git log upstream/master.. --oneline") + run("git diff upstream/master.. --name-status") print() - print('PR: %d' % (pull_id,)) - print(subprocess.check_output(['git', 'log', 'HEAD~..', - '--pretty=format:Author: %an'])) + print("PR: %d" % (pull_id,)) + print(subprocess.check_output(["git", "log", "HEAD~..", "--pretty=format:Author: %an"])) + def review_pr() -> None: try: pull_id = int(sys.argv[1]) except Exception: - exit('please provide an integer pull request id') + exit("please provide an integer pull request id") ensure_on_clean_master() create_pull_branch(pull_id) -if __name__ == '__main__': + +if __name__ == "__main__": review_pr() diff --git a/tools/run-mypy b/tools/run-mypy index 357c599e04..3c47e211a0 100755 --- a/tools/run-mypy +++ b/tools/run-mypy @@ -1,14 +1,14 @@ #!/usr/bin/env python3 -import os -import sys import argparse +import os import subprocess - +import sys from collections import OrderedDict from pathlib import PurePath +from typing import Dict, List, cast + from zulint import lister -from typing import cast, Dict, List TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) os.chdir(os.path.dirname(TOOLS_DIR)) @@ -99,51 +99,94 @@ 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: - ext = os.path.splitext(inpath)[1].split('.')[1] + ext = os.path.splitext(inpath)[1].split(".")[1] except IndexError: - ext = 'py' # type: str + ext = "py" # type: str 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] +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 +] -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: @@ -164,7 +207,7 @@ if args.quick: # run mypy status = 0 for repo, python_files in repo_python_files.items(): - print("Running mypy for `{}`.".format(repo), flush=True) + print(f"Running mypy for `{repo}`.", flush=True) if python_files: result = subprocess.call([mypy_command] + extra_args + python_files) if result != 0: diff --git a/tools/server_lib/test_handler.py b/tools/server_lib/test_handler.py index a1d47e56a5..823979f36a 100644 --- a/tools/server_lib/test_handler.py +++ b/tools/server_lib/test_handler.py @@ -1,51 +1,57 @@ - +import argparse import os +import shutil import sys -import argparse import unittest + import pytest -import shutil 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 = argparse.ArgumentParser(description=f"Run tests for {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)", + ) options = parser.parse_args() - test_session_title = ' Running tests for {} '.format(package_name) - header = test_session_title.center(shutil.get_terminal_size().columns, '#') + test_session_title = f" Running tests for {package_name} " + header = test_session_title.center(shutil.get_terminal_size().columns, "#") print(header) if options.coverage: import coverage + cov = coverage.Coverage(config_file="tools/.coveragerc") - if options.coverage == 'combine': + if options.coverage == "combine": cov.load() cov.start() if options.pytest: - location_to_run_in = os.path.join(TOOLS_DIR, '..', *path_list) - paths_to_test = ['.'] + 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 - '--ff', # runs last failure first + "-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 5f62489447..cad9c514b4 100755 --- a/tools/test-bots +++ b/tools/test-bots @@ -1,14 +1,15 @@ #!/usr/bin/env python3 -from os.path import dirname, basename - -import os -import sys import argparse import glob +import os +import sys import unittest +from os.path import basename, dirname + import pytest + def parse_args(): description = """ Script to run test_.py files in the @@ -30,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() @@ -64,8 +69,8 @@ def main(): TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) os.chdir(os.path.dirname(TOOLS_DIR)) sys.path.insert(0, TOOLS_DIR) - bots_dir = os.path.join(TOOLS_DIR, '..', 'zulip_bots/zulip_bots/bots') - glob_pattern = bots_dir + '/*/test_*.py' + bots_dir = os.path.join(TOOLS_DIR, "..", "zulip_bots/zulip_bots/bots") + glob_pattern = bots_dir + "/*/test_*.py" test_modules = glob.glob(glob_pattern) # get only the names of bots that have tests @@ -75,8 +80,9 @@ def main(): if options.coverage: import coverage + cov = coverage.Coverage(config_file="tools/.coveragerc") - if options.coverage == 'combine': + if options.coverage == "combine": cov.load() cov.start() @@ -90,14 +96,14 @@ def main(): bots_to_test = {bot for bot in specified_bots if bot not in options.exclude} if options.pytest: - excluded_bots = ['merels'] - pytest_bots_to_test = sorted([bot for bot in bots_to_test if bot not in excluded_bots]) + 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 - '--ff', # runs last failure first + "-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: @@ -115,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) @@ -133,5 +141,6 @@ def main(): cov.html_report() print("HTML report saved under directory 'htmlcov'.") -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/tools/test-botserver b/tools/test-botserver index ee9025b2ae..7f0614f42d 100755 --- a/tools/test-botserver +++ b/tools/test-botserver @@ -2,5 +2,5 @@ from server_lib.test_handler import handle_input_and_run_tests_for_package -if __name__ == '__main__': - handle_input_and_run_tests_for_package('Botserver', ['zulip_botserver']) +if __name__ == "__main__": + handle_input_and_run_tests_for_package("Botserver", ["zulip_botserver"]) diff --git a/tools/test-lib b/tools/test-lib index 4ee0e88cb2..01fc959466 100755 --- a/tools/test-lib +++ b/tools/test-lib @@ -2,5 +2,5 @@ from server_lib.test_handler import handle_input_and_run_tests_for_package -if __name__ == '__main__': - handle_input_and_run_tests_for_package('Bot library', ['zulip_bots', 'zulip_bots', 'tests']) +if __name__ == "__main__": + handle_input_and_run_tests_for_package("Bot library", ["zulip_bots", "zulip_bots", "tests"]) diff --git a/tools/test-zulip b/tools/test-zulip index 4d22d742dc..490e73d7f9 100755 --- a/tools/test-zulip +++ b/tools/test-zulip @@ -2,5 +2,5 @@ from server_lib.test_handler import handle_input_and_run_tests_for_package -if __name__ == '__main__': - handle_input_and_run_tests_for_package('API', ['zulip']) +if __name__ == "__main__": + handle_input_and_run_tests_for_package("API", ["zulip"]) diff --git a/zulip/integrations/bridge_between_zulips/interrealm_bridge_config.py b/zulip/integrations/bridge_between_zulips/interrealm_bridge_config.py index c28cd7c42e..13886d393f 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 @@ "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 97434bfcae..562c16836a 100755 --- a/zulip/integrations/bridge_between_zulips/run-interrealm-bridge +++ b/zulip/integrations/bridge_between_zulips/run-interrealm-bridge @@ -1,18 +1,19 @@ #!/usr/bin/env python3 -import sys -import os import argparse import multiprocessing as mp -import zulip +import os +import sys +from typing import Any, Callable, Dict + import interrealm_bridge_config -from typing import Any, Callable, Dict +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"]) @@ -31,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 @@ -44,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)) @@ -54,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] @@ -67,23 +71,18 @@ if __name__ == "__main__": all topics within the stream are mirrored as-is without translation. """ - sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + 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 0d767ff984..2f30a13fab 100755 --- a/zulip/integrations/bridge_with_irc/irc-mirror.py +++ b/zulip/integrations/bridge_with_irc/irc-mirror.py @@ -5,10 +5,11 @@ # import argparse -import zulip import sys import traceback +import zulip + usage = """./irc-mirror.py --irc-server=IRC_SERVER --channel= --nick-prefix= --stream= [optional args] Example: @@ -25,14 +26,16 @@ """ if __name__ == "__main__": - 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) - parser.add_argument('--channel', default=None) - parser.add_argument('--stream', default="general") - parser.add_argument('--topic', default="IRC") - parser.add_argument('--nickserv-pw', default='') + 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) + parser.add_argument("--channel", default=None) + parser.add_argument("--stream", default="general") + parser.add_argument("--topic", default="IRC") + parser.add_argument("--nickserv-pw", default="") options = parser.parse_args() # Setting the client to irc_mirror is critical for this to work @@ -42,14 +45,24 @@ 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 9d8ca8f5ad..9ad2fcb155 100644 --- a/zulip/integrations/bridge_with_irc/irc_mirror_backend.py +++ b/zulip/integrations/bridge_with_irc/irc_mirror_backend.py @@ -1,16 +1,26 @@ +import multiprocessing as mp +from typing import Any, Dict + import irc.bot import irc.strings from irc.client import Event, ServerConnection, ip_numstr_to_quad from irc.client_aio import AioReactor -import multiprocessing as mp -from typing import Any, Dict 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 @@ -30,19 +40,20 @@ def connect(self, *args: Any, **kwargs: Any) -> None: # 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: resp = self.zulip_client.get_subscriptions() if resp["result"] != "success": - print("ERROR: %s" % (resp["msg"],)) + print("ERROR: {}".format(resp["msg"])) 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: @@ -50,8 +61,8 @@ def on_nicknameinuse(self, c: ServerConnection, e: Event) -> None: def on_welcome(self, c: ServerConnection, e: Event) -> None: if len(self.nickserv_password) > 0: - msg = 'identify %s' % (self.nickserv_password,) - c.privmsg('NickServ', msg) + msg = f"identify {self.nickserv_password}" + c.privmsg("NickServ", msg) c.join(self.channel) def forward_to_irc(msg: Dict[str, Any]) -> None: @@ -64,13 +75,16 @@ def forward_to_irc(msg: Dict[str, Any]) -> None: in_the_specified_stream = msg["display_recipient"] == self.stream at_the_specified_subject = msg["subject"].casefold() == self.topic.casefold() if in_the_specified_stream and at_the_specified_subject: - msg["content"] = ("@**%s**: " % (msg["sender_full_name"],)) + msg["content"] + msg["content"] = ("@**{}**: ".format(msg["sender_full_name"])) + msg["content"] send = lambda x: self.c.privmsg(self.channel, x) 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: @@ -88,12 +102,16 @@ def on_privmsg(self, c: ServerConnection, e: Event) -> None: 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] @@ -102,12 +120,16 @@ def on_pubmsg(self, c: ServerConnection, e: Event) -> None: 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": f"**{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 a0665d0005..6c0b87353d 100644 --- a/zulip/integrations/bridge_with_matrix/matrix_bridge.py +++ b/zulip/integrations/bridge_with_matrix/matrix_bridge.py @@ -1,43 +1,45 @@ #!/usr/bin/env python3 -import os +import argparse +import configparser import logging +import os +import re import signal -import traceback -import zulip import sys -import argparse -import re -import configparser - +import traceback from collections import OrderedDict - from types import FrameType from typing import Any, Callable, Dict, Optional -from matrix_client.errors import MatrixRequestError from matrix_client.client import MatrixClient +from matrix_client.errors import MatrixRequestError from requests.exceptions import MissingSchema -GENERAL_NETWORK_USERNAME_REGEX = '@_?[a-zA-Z0-9]+_([a-zA-Z0-9-_]+):[a-zA-Z0-9.]+' -MATRIX_USERNAME_REGEX = '@([a-zA-Z0-9-_]+):matrix.org' +import zulip + +GENERAL_NETWORK_USERNAME_REGEX = "@_?[a-zA-Z0-9]+_([a-zA-Z0-9-_]+):[a-zA-Z0-9.]+" +MATRIX_USERNAME_REGEX = "@([a-zA-Z0-9-_]+):matrix.org" # change these templates to change the format of displayed message 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.") @@ -46,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"]) @@ -56,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], @@ -72,49 +77,52 @@ def _matrix_to_zulip(room: Any, event: Dict[str, Any]) -> None: """ content = get_message_content_from_event(event, no_noise) - zulip_bot_user = '@%s:matrix.org' % (matrix_config['username'],) + zulip_bot_user = "@{}:matrix.org".format(matrix_config["username"]) # We do this to identify the messages generated from Zulip -> Matrix # and we make sure we don't forward it again to the Zulip stream. - not_from_zulip_bot = event['sender'] != zulip_bot_user + not_from_zulip_bot = event["sender"] != zulip_bot_user 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) - if result['result'] != 'success': + if result["result"] != "success": # Generally raised when API key is invalid - raise Bridge_ZulipFatalException(result['msg']) + raise Bridge_ZulipFatalException(result["msg"]) 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": + irc_nick = shorten_irc_nick(event["sender"]) + if event["type"] == "m.room.member": if no_noise: return None # 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") - elif event['membership'] == "leave": - 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']) + if event["membership"] == "join": + content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="joined") + elif event["membership"] == "leave": + 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"] + ) else: - content = event['type'] + content = event["type"] return content + def shorten_irc_nick(nick: str) -> str: """ Add nick shortner functions for specific IRC networks @@ -131,21 +139,24 @@ 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 """ 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_username = msg["sender_full_name"].replace(" ", "") + 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"] @@ -158,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. @@ -170,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() @@ -191,36 +218,49 @@ def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]: except configparser.Error as exception: raise Bridge_ConfigException(str(exception)) - if set(config.sections()) != {'matrix', 'zulip'}: + if set(config.sections()) != {"matrix", "zulip"}: raise Bridge_ConfigException("Please ensure the configuration has zulip & matrix sections.") # TODO Could add more checks for configuration content here 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)) - - 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'), - ))), - )) + raise Bridge_ConfigException(f"Path '{target_path}' exists; not overwriting existing file.") + + 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): - raise Bridge_ConfigException("Zuliprc file '{}' does not exist.".format(zuliprc)) + raise Bridge_ConfigException(f"Zuliprc file '{zuliprc}' does not exist.") zuliprc_config = configparser.ConfigParser() try: @@ -230,15 +270,16 @@ def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None: # Can add more checks for validity of zuliprc file here - sample_dict['zulip']['email'] = zuliprc_config['api']['email'] - sample_dict['zulip']['site'] = zuliprc_config['api']['site'] - sample_dict['zulip']['api_key'] = zuliprc_config['api']['key'] + sample_dict["zulip"]["email"] = zuliprc_config["api"]["email"] + sample_dict["zulip"]["site"] = zuliprc_config["api"]["site"] + sample_dict["zulip"]["api_key"] = zuliprc_config["api"]["key"] sample = configparser.ConfigParser() sample.read_dict(sample_dict) - with open(target_path, 'w') as target: + with open(target_path, "w") as target: sample.write(target) + def main() -> None: signal.signal(signal.SIGINT, die) logging.basicConfig(level=logging.WARNING) @@ -250,13 +291,16 @@ def main() -> None: try: write_sample_config(options.sample_config, options.zuliprc) except Bridge_ConfigException as exception: - print("Could not write sample config: {}".format(exception)) + print(f"Could not write sample config: {exception}") sys.exit(1) if options.zuliprc is None: - print("Wrote sample configuration to '{}'".format(options.sample_config)) + print(f"Wrote sample configuration to '{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.") @@ -266,7 +310,7 @@ def main() -> None: try: config = read_configuration(options.config) except Bridge_ConfigException as exception: - print("Could not parse config file: {}".format(exception)) + print(f"Could not parse config file: {exception}") sys.exit(1) # Get config for each client @@ -278,9 +322,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 @@ -288,8 +334,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() @@ -298,14 +345,15 @@ def main() -> None: zulip_client.call_on_each_message(zulip_to_matrix(zulip_config, room)) except Bridge_FatalMatrixException as exception: - sys.exit("Matrix bridge error: {}".format(exception)) + sys.exit(f"Matrix bridge error: {exception}") except Bridge_ZulipFatalException as exception: - sys.exit("Zulip bridge error: {}".format(exception)) + sys.exit(f"Zulip bridge error: {exception}") except zulip.ZulipError as exception: - sys.exit("Zulip error: {}".format(exception)) + sys.exit(f"Zulip error: {exception}") except Exception: traceback.print_exc() backoff.fail() -if __name__ == '__main__': + +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 11e74cfe18..296d9efab8 100644 --- a/zulip/integrations/bridge_with_matrix/test_matrix.py +++ b/zulip/integrations/bridge_with_matrix/test_matrix.py @@ -1,22 +1,17 @@ -from .matrix_bridge import ( - check_zulip_message_validity, - zulip_to_matrix, -) - -from unittest import TestCase, mock -from subprocess import Popen, PIPE import os - import shutil - from contextlib import contextmanager +from subprocess import PIPE, Popen from tempfile import mkdtemp +from unittest import TestCase, mock + +from .matrix_bridge import check_zulip_message_validity, zulip_to_matrix script_file = "matrix_bridge.py" script_dir = os.path.dirname(__file__) script = os.path.join(script_dir, script_file) -from typing import List, Iterator +from typing import Iterator, List sample_config_path = "matrix_test.conf" @@ -35,32 +30,36 @@ """ + @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) + f"usage: {script_file} [-h]", ] for expected, output in zip(expected_lines, output_lines): self.assertIn(expected, output) def test_help_usage_and_description(self) -> None: output_lines = self.output_from_script(["-h"]) - usage = "usage: {} [-h]".format(script_file) + usage = f"usage: {script_file} [-h]" description = "Script to bridge" self.assertIn(usage, output_lines[0]) - blank_lines = [num for num, line in enumerate(output_lines) if line == ''] + blank_lines = [num for num, line in enumerate(output_lines) if line == ""] # There should be blank lines in the output self.assertTrue(blank_lines) # There should be finite output @@ -72,33 +71,41 @@ def test_write_sample_config(self) -> None: with new_temp_dir() as tempdir: path = os.path.join(tempdir, sample_config_path) output_lines = self.output_from_script(["--write-sample-config", path]) - self.assertEqual(output_lines, ["Wrote sample configuration to '{}'".format(path)]) + self.assertEqual(output_lines, [f"Wrote sample configuration to '{path}'"]) with open(path) as sample_file: self.assertEqual(sample_file.read(), sample_config_text) 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()] expected_lines = sample_config_text.split("\n") - expected_lines[7] = 'email = {}'.format(zulip_params['email']) - expected_lines[8] = 'api_key = {}'.format(zulip_params['key']) - expected_lines[9] = 'site = {}'.format(zulip_params['site']) + expected_lines[7] = "email = {}".format(zulip_params["email"]) + expected_lines[8] = "api_key = {}".format(zulip_params["key"]) + expected_lines[9] = "site = {}".format(zulip_params["site"]) self.assertEqual(sample_lines, expected_lines[:-1]) def test_detect_zuliprc_does_not_exist(self) -> None: @@ -106,46 +113,49 @@ def test_detect_zuliprc_does_not_exist(self) -> None: 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'] + display_recipient=valid_zulip_config["stream"], + subject=valid_zulip_config["topic"], ) def test_zulip_message_validity_success(self) -> None: zulip_config = self.valid_zulip_config msg = self.valid_msg # Ensure the test inputs are valid for success - assert msg['sender_email'] != zulip_config['email'] + assert msg["sender_email"] != zulip_config["email"] self.assertTrue(check_zulip_message_validity(msg, zulip_config)) def test_zulip_message_validity_failure(self) -> None: zulip_config = self.valid_zulip_config - msg_wrong_stream = dict(self.valid_msg, display_recipient='foo') + msg_wrong_stream = dict(self.valid_msg, display_recipient="foo") self.assertFalse(check_zulip_message_validity(msg_wrong_stream, zulip_config)) - msg_wrong_topic = dict(self.valid_msg, subject='foo') + msg_wrong_topic = dict(self.valid_msg, subject="foo") self.assertFalse(check_zulip_message_validity(msg_wrong_topic, zulip_config)) msg_not_stream = dict(self.valid_msg, type="private") self.assertFalse(check_zulip_message_validity(msg_not_stream, zulip_config)) - msg_from_bot = dict(self.valid_msg, sender_email=zulip_config['email']) + msg_from_bot = dict(self.valid_msg, sender_email=zulip_config["email"]) self.assertFalse(check_zulip_message_validity(msg_from_bot, zulip_config)) def test_zulip_to_matrix(self) -> None: @@ -156,14 +166,14 @@ def test_zulip_to_matrix(self) -> None: msg = dict(self.valid_msg, sender_full_name="John Smith") expected = { - 'hi': '{} hi', - '*hi*': '{} *hi*', - '**hi**': '{} **hi**', + "hi": "{} hi", + "*hi*": "{} *hi*", + "**hi**": "{} **hi**", } for content in expected: send_msg(dict(msg, content=content)) for (method, params, _), expect in zip(room.method_calls, expected.values()): - self.assertEqual(method, 'send_text') - self.assertEqual(params[0], expect.format('')) + self.assertEqual(method, "send_text") + self.assertEqual(params[0], expect.format("")) 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 ce4178698a..2a85a6115b 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 @@ "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 984de8b5ce..93b0c8323c 100755 --- a/zulip/integrations/bridge_with_slack/run-slack-bridge +++ b/zulip/integrations/bridge_with_slack/run-slack-bridge @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- import argparse import os @@ -8,16 +7,17 @@ import threading import traceback from typing import Any, Callable, Dict +import bridge_with_slack_config import slack_sdk -import zulip from slack_sdk.rtm import RTMClient -import bridge_with_slack_config +import zulip # change these templates to change the format of displayed message 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 +30,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 +41,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"] @@ -52,58 +54,61 @@ class SlackBridge: self.slack_webclient = slack_sdk.WebClient(token=self.slack_config["token"]) def wrap_slack_mention_with_bracket(self, zulip_msg: Dict[str, Any]) -> None: - words = zulip_msg["content"].split(' ') + words = zulip_msg["content"].split(" ") for w in words: - if w.startswith('@'): - zulip_msg["content"] = zulip_msg["content"].replace(w, '<' + w + '>') + if w.startswith("@"): + zulip_msg["content"] = zulip_msg["content"].replace(w, "<" + w + ">") def replace_slack_id_with_name(self, msg: Dict[str, Any]) -> None: - words = msg['text'].split(' ') + words = msg["text"].split(" ") for w in words: - if w.startswith('<@') and w.endswith('>'): + if w.startswith("<@") and w.endswith(">"): _id = w[2:-1] - msg['text'] = msg['text'].replace(_id, self.slack_id_to_name[_id]) + msg["text"] = msg["text"].replace(_id, self.slack_id_to_name[_id]) def zulip_to_slack(self) -> Callable[[Dict[str, Any]], None]: def _zulip_to_slack(msg: Dict[str, Any]) -> None: 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'] + 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') + @RTMClient.run_on(event="message") def slack_to_zulip(**payload: Any) -> None: - msg = payload['data'] - if msg['channel'] != self.channel: + msg = payload["data"] + if msg["channel"] != self.channel: return - user_id = msg['user'] + user_id = msg["user"] user = self.slack_id_to_name[user_id] - from_bot = user == self.slack_config['username'] + from_bot = user == self.slack_config["username"] if from_bot: return self.replace_slack_id_with_name(msg) - content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=msg['text']) + 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 @@ -111,7 +116,7 @@ if __name__ == "__main__": the first realm to a channel in a Slack workspace. """ - sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + sys.path.append(os.path.join(os.path.dirname(__file__), "..")) parser = argparse.ArgumentParser(usage=usage) print("Starting slack mirroring bot") @@ -124,7 +129,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 45051be078..88264ca8ec 100755 --- a/zulip/integrations/codebase/zulip_codebase_mirror +++ b/zulip/integrations/codebase/zulip_codebase_mirror @@ -8,15 +8,15 @@ # # python-dateutil is a dependency for this script. -import requests import logging -import pytz -import time -import sys import os - +import sys +import time from datetime import datetime, timedelta +import pytz +import requests + try: import dateutil.parser except ImportError as e: @@ -26,22 +26,25 @@ except ImportError as e: sys.path.insert(0, os.path.dirname(__file__)) import zulip_codebase_config as config + VERSION = "0.9" if config.ZULIP_API_PATH is not None: sys.path.append(config.ZULIP_API_PATH) +from typing import Any, Dict, List, Optional + import zulip -from typing import Any, List, Dict, Optional 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. -json_implementations = ['ujson', 'cjson', 'simplejson', 'json'] +json_implementations = ["ujson", "cjson", "simplejson", "json"] while len(json_implementations): try: @@ -50,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( + f"https://api3.codebasehq.com/{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) @@ -67,173 +75,208 @@ 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( + f"Found non-success response status code: {response.status_code} {response.text}" + ) return None + def make_url(path: str) -> str: - return "%s/%s" % (config.CODEBASE_ROOT_URL, path) + return f"{config.CODEBASE_ROOT_URL}/{path}" + def handle_event(event: Dict[str, Any]) -> None: - event = event['event'] - event_type = event['type'] - actor_name = event['actor_name'] + event = event["event"] + event_type = event["type"] + actor_name = event["actor_name"] - raw_props = event.get('raw_properties', {}) + raw_props = event.get("raw_properties", {}) - project_link = raw_props.get('project_permalink') + project_link = raw_props.get("project_permalink") subject = None content = None - if event_type == 'repository_creation': + if event_type == "repository_creation": stream = config.ZULIP_COMMITS_STREAM_NAME - project_name = raw_props.get('name') - project_repo_type = raw_props.get('scm_type') + project_name = raw_props.get("name") + project_repo_type = raw_props.get("scm_type") - url = make_url("projects/%s" % (project_link,)) - scm = "of type %s" % (project_repo_type,) if project_repo_type else "" + url = make_url(f"projects/{project_link}") + scm = f"of type {project_repo_type}" if project_repo_type else "" - subject = "Repository %s Created" % (project_name,) - content = "%s created a new repository %s [%s](%s)" % (actor_name, scm, project_name, url) - elif event_type == 'push': + subject = f"Repository {project_name} Created" + content = f"{actor_name} created a new repository {scm} [{project_name}]({url})" + elif event_type == "push": stream = config.ZULIP_COMMITS_STREAM_NAME - num_commits = raw_props.get('commits_count') - branch = raw_props.get('ref_name') - project = raw_props.get('project_name') - repo_link = raw_props.get('repository_permalink') - deleted_ref = raw_props.get('deleted_ref') - new_ref = raw_props.get('new_ref') + num_commits = raw_props.get("commits_count") + branch = raw_props.get("ref_name") + project = raw_props.get("project_name") + repo_link = raw_props.get("repository_permalink") + deleted_ref = raw_props.get("deleted_ref") + new_ref = raw_props.get("new_ref") - subject = "Push to %s on %s" % (branch, project) + subject = f"Push to {branch} on {project}" if deleted_ref: - content = "%s deleted branch %s from %s" % (actor_name, branch, project) + content = f"{actor_name} deleted branch {branch} from {project}" 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)) - 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)) - message = commit.get('message') - content += "* [%s](%s): %s\n" % (ref, url, message) - elif event_type == 'ticketing_ticket': + branch = f"new branch {branch}" + content = "{} pushed {} commit(s) to {} in project {}:\n\n".format( + actor_name, + num_commits, + branch, + project, + ) + for commit in raw_props.get("commits"): + ref = commit.get("ref") + url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{ref}") + message = commit.get("message") + content += f"* [{ref}]({url}): {message}\n" + elif event_type == "ticketing_ticket": stream = config.ZULIP_TICKETS_STREAM_NAME - num = raw_props.get('number') - name = raw_props.get('subject') - assignee = raw_props.get('assignee') - priority = raw_props.get('priority') - url = make_url("projects/%s/tickets/%s" % (project_link, num)) + num = raw_props.get("number") + name = raw_props.get("subject") + assignee = raw_props.get("assignee") + priority = raw_props.get("priority") + url = make_url(f"projects/{project_link}/tickets/{num}") 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)) - elif event_type == 'ticketing_note': + subject = f"#{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) + ) + elif event_type == "ticketing_note": stream = config.ZULIP_TICKETS_STREAM_NAME - num = raw_props.get('number') - name = raw_props.get('subject') - body = raw_props.get('content') - changes = raw_props.get('changes') + num = raw_props.get("number") + name = raw_props.get("subject") + body = raw_props.get("content") + changes = raw_props.get("changes") - url = make_url("projects/%s/tickets/%s" % (project_link, num)) - subject = "#%s: %s" % (num, name) + url = make_url(f"projects/{project_link}/tickets/{num}") + subject = f"#{num}: {name}" 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) - - 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]) - elif event_type == 'ticketing_milestone': + content = "{} added a comment to ticket [#{}]({}):\n\n~~~ quote\n{}\n\n".format( + actor_name, + num, + url, + body, + ) + + if "status_id" in changes: + status_change = changes.get("status_id") + content += "Status changed from **{}** to **{}**\n\n".format( + status_change[0], + status_change[1], + ) + elif event_type == "ticketing_milestone": stream = config.ZULIP_TICKETS_STREAM_NAME - name = raw_props.get('name') - identifier = raw_props.get('identifier') - url = make_url("projects/%s/milestone/%s" % (project_link, identifier)) + name = raw_props.get("name") + identifier = raw_props.get("identifier") + url = make_url(f"projects/{project_link}/milestone/{identifier}") subject = name - content = "%s created a new milestone [%s](%s)" % (actor_name, name, url) - elif event_type == 'comment': + content = f"{actor_name} created a new milestone [{name}]({url})" + elif event_type == "comment": stream = config.ZULIP_COMMITS_STREAM_NAME - comment = raw_props.get('content') - commit = raw_props.get('commit_ref') + comment = raw_props.get("content") + commit = raw_props.get("commit_ref") # If there's a commit id, it's a comment to a commit if commit: - repo_link = raw_props.get('repository_permalink') + repo_link = raw_props.get("repository_permalink") - url = make_url('projects/%s/repositories/%s/commit/%s' % (project_link, repo_link, commit)) + url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{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) + subject = f"{actor_name} commented on {commit}" + content = "{} commented on [{}]({}):\n\n~~~ quote\n{}".format( + actor_name, + commit, + url, + comment, + ) else: # Otherwise, this is a Discussion item, and handle it subj = raw_props.get("subject") category = raw_props.get("category") comment_content = raw_props.get("content") - subject = "Discussion: %s" % (subj,) + subject = f"Discussion: {subj}" if category: format_str = "%s started a new discussion in %s:\n\n~~~ quote\n%s\n~~~" content = format_str % (actor_name, category, comment_content) else: - content = "%s posted:\n\n~~~ quote\n%s\n~~~" % (actor_name, comment_content) + content = f"{actor_name} posted:\n\n~~~ quote\n{comment_content}\n~~~" - elif event_type == 'deployment': + elif event_type == "deployment": stream = config.ZULIP_COMMITS_STREAM_NAME - start_ref = raw_props.get('start_ref') - end_ref = raw_props.get('end_ref') - environment = raw_props.get('environment') - 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)) - - 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)) + start_ref = raw_props.get("start_ref") + end_ref = raw_props.get("end_ref") + environment = raw_props.get("environment") + servers = raw_props.get("servers") + repo_link = raw_props.get("repository_permalink") + + start_ref_url = make_url( + f"projects/{project_link}/repositories/{repo_link}/commit/{start_ref}" + ) + end_ref_url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{end_ref}") + between_url = make_url( + "projects/%s/repositories/%s/compare/%s...%s" + % (project_link, repo_link, start_ref, end_ref) + ) + + subject = f"Deployment to {environment}" + + content = "{} deployed [{}]({}) [through]({}) [{}]({}) to the **{}** environment.".format( + 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(f"`{server}`" for server in servers) + ) - elif event_type == 'named_tree': + elif event_type == "named_tree": # Docs say named_tree type used for new/deleting branches and tags, # but experimental testing showed that they were all sent as 'push' events pass - elif event_type == 'wiki_page': + elif event_type == "wiki_page": logging.warn("Wiki page notifications not yet implemented") - elif event_type == 'sprint_creation': + elif event_type == "sprint_creation": logging.warn("Sprint notifications not yet implemented") - elif event_type == 'sprint_ended': + elif event_type == "sprint_ended": logging.warn("Sprint notifications not yet implemented") else: - logging.info("Unknown event type %s, ignoring!" % (event_type,)) + logging.info(f"Unknown event type {event_type}, ignoring!") if subject and content: if len(subject) > 60: - subject = subject[:57].rstrip() + '...' - - 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'],)) + subject = subject[:57].rstrip() + "..." + + res = client.send_message( + {"type": "stream", "to": stream, "subject": subject, "content": content} + ) + if res["result"] == "success": + logging.info("Successfully sent Zulip with id: {}".format(res["id"])) else: - logging.warn("Failed to send Zulip: %s %s" % (res['result'], res['msg'])) + logging.warn("Failed to send Zulip: {} {}".format(res["result"], res["msg"])) # the main run loop for this mirror script @@ -246,12 +289,12 @@ def run_mirror() -> None: try: with open(config.RESUME_FILE) as f: timestamp = f.read() - if timestamp == '': + if timestamp == "": since = default_since() else: since = datetime.fromtimestamp(float(timestamp), tz=pytz.utc) except (ValueError, OSError) as e: - logging.warn("Could not open resume file: %s" % (str(e),)) + logging.warn(f"Could not open resume file: {str(e)}") since = default_since() try: @@ -261,7 +304,7 @@ def run_mirror() -> None: if events is not None: sleepInterval = 1 for event in events[::-1]: - timestamp = event.get('event', {}).get('timestamp', '') + timestamp = event.get("event", {}).get("timestamp", "") event_date = dateutil.parser.parse(timestamp) if event_date > since: handle_event(event) @@ -273,9 +316,10 @@ def run_mirror() -> None: time.sleep(sleepInterval) except KeyboardInterrupt: - open(config.RESUME_FILE, 'w').write(since.strftime("%s")) + 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 @@ -289,9 +333,10 @@ 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(f"Could not open up the file {config.RESUME_FILE} for reading and writing") 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 95e7633ba6..74b5110fa3 100755 --- a/zulip/integrations/git/post-receive +++ b/zulip/integrations/git/post-receive @@ -9,48 +9,52 @@ # For example: # aa453216d1b3e49e7f6f98441fa56946ddcd6a20 68f7abf4e6f922807889f52bc043ecd31b79f814 refs/heads/master -from typing import Text import os -import sys -import subprocess import os.path +import subprocess +import sys sys.path.insert(0, os.path.dirname(__file__)) import zulip_git_config as config + VERSION = "0.9" if config.ZULIP_API_PATH is not None: sys.path.append(config.ZULIP_API_PATH) import zulip + 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: +def git_repository_name() -> str: 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)] - commits = '' + log_cmd = ["git", "log", "--reverse", "--pretty=%aE %H %s", f"{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) + commits += f"!avatar({author_email}) {subject}\n" 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 @@ -60,30 +64,30 @@ def send_bot_message(oldrev: str, newrev: str, refname: str) -> None: old_head = oldrev[:12] if ( - oldrev == '0000000000000000000000000000000000000000' - or newrev == '0000000000000000000000000000000000000000' + oldrev == "0000000000000000000000000000000000000000" + or newrev == "0000000000000000000000000000000000000000" ): # New branch pushed or old branch removed - added = '' - removed = '' + added = "" + removed = "" else: - added = git_commit_range(oldrev, newrev) + added = git_commit_range(oldrev, newrev) removed = git_commit_range(newrev, oldrev) - if oldrev == '0000000000000000000000000000000000000000': - message = '`%s` was pushed to new branch `%s`' % (new_head, branch) - elif newrev == '0000000000000000000000000000000000000000': - message = 'branch `%s` was removed (was `%s`)' % (branch, old_head) + if oldrev == "0000000000000000000000000000000000000000": + message = f"`{new_head}` was pushed to new branch `{branch}`" + elif newrev == "0000000000000000000000000000000000000000": + message = f"branch `{branch}` was removed (was `{old_head}`)" elif removed: - message = '`%s` was pushed to `%s`, **REMOVING**:\n\n%s' % (new_head, branch, removed) + message = f"`{new_head}` was pushed to `{branch}`, **REMOVING**:\n\n{removed}" if added: - message += '\n**and adding**:\n\n' + added - message += '\n**A HISTORY REWRITE HAS OCCURRED!**' - message += '\n@everyone: Please check your local branches to deal with this.' + message += "\n**and adding**:\n\n" + added + message += "\n**A HISTORY REWRITE HAS OCCURRED!**" + message += "\n@everyone: Please check your local branches to deal with this." elif added: - message = '`%s` was deployed to `%s` with:\n\n%s' % (new_head, branch, added) + message = f"`{new_head}` was deployed to `{branch}` with:\n\n{added}" else: - message = '`%s` was pushed to `%s`... but nothing changed?' % (new_head, branch) + message = f"`{new_head}` was pushed to `{branch}`... but nothing changed?" message_data = { "type": "stream", @@ -93,6 +97,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 6000a09e3d..0f7fe7a769 100644 --- a/zulip/integrations/git/zulip_git_config.py +++ b/zulip/integrations/git/zulip_git_config.py @@ -1,8 +1,8 @@ # -from typing import Dict, Text, Optional +from typing import Dict, Optional # Name of the stream to send notifications to, default is "commits" -STREAM_NAME = 'commits' +STREAM_NAME = "commits" # Change these values to configure authentication for the plugin ZULIP_USER = "git-bot@example.com" @@ -23,21 +23,22 @@ # * stream "commits" # * topic "master" # 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]]: +def commit_notice_destination(repo: str, branch: str, commit: str) -> Optional[Dict[str, str]]: if branch in ["master", "test-post-receive"]: - return dict(stream = STREAM_NAME, - subject = "%s" % (branch,)) + return dict(stream=STREAM_NAME, subject=f"{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. # # return '!avatar(%s) [%s](https://example.com/commits/%s)\n' % (author, subject, commit_id) -def format_commit_message(author: Text, subject: Text, commit_id: Text) -> Text: - return '!avatar(%s) %s\n' % (author, subject) +def format_commit_message(author: str, subject: str, commit_id: str) -> str: + return f"!avatar({author}) {subject}\n" + ## 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/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index 2cc5abe2c8..558366a40b 100644 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -1,15 +1,16 @@ #!/usr/bin/env python3 import os +from typing import Optional -from oauth2client import client -from oauth2client import tools +from oauth2client import client, tools from oauth2client.file import Storage -from typing import Optional - 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 @@ -17,12 +18,13 @@ except ImportError: # at zulip/bots/gcal/ # NOTE: When adding more scopes, add them after the previous one in the same field, with a space # seperating them. -SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' +SCOPES = "https://www.googleapis.com/auth/calendar.readonly" # This file contains the information that google uses to figure out which application is requesting # this client's data. -CLIENT_SECRET_FILE = 'client_secret.json' -APPLICATION_NAME = 'Zulip Calendar Bot' -HOME_DIR = os.path.expanduser('~') +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. @@ -34,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,6 +50,7 @@ def get_credentials() -> client.Credentials: credentials = tools.run_flow(flow, store, flags) else: # Needed only for compatibility with Python 2.6 credentials = tools.run(flow, store) - print('Storing credentials to ' + credential_path) + print("Storing credentials to " + credential_path) + get_credentials() diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 231c255230..311d94f592 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -2,32 +2,33 @@ # # This script depends on python-dateutil and python-pytz for properly handling # times and time zones of calendar events. +import argparse import datetime -import dateutil.parser -import httplib2 import itertools import logging -import argparse import os -import pytz import sys import time from typing import List, Optional, Set, Tuple +import dateutil.parser +import httplib2 +import pytz from oauth2client import client from oauth2client.file import Storage + try: from googleapiclient import discovery except ImportError: - logging.exception('Install google-api-python-client') + logging.exception("Install google-api-python-client") -sys.path.append(os.path.join(os.path.dirname(__file__), '../../')) +sys.path.append(os.path.join(os.path.dirname(__file__), "../../")) import zulip -SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' -CLIENT_SECRET_FILE = 'client_secret.json' -APPLICATION_NAME = 'Zulip' -HOME_DIR = os.path.expanduser('~') +SCOPES = "https://www.googleapis.com/auth/calendar.readonly" +CLIENT_SECRET_FILE = "client_secret.json" +APPLICATION_NAME = "Zulip" +HOME_DIR = os.path.expanduser("~") # Our cached view of the calendar, updated periodically. events = [] # type: List[Tuple[int, datetime.datetime, str]] @@ -37,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 @@ -52,31 +55,38 @@ 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('--calendar', - dest = 'calendarID', - default = 'primary', - type = str, - action = 'store', - help = 'Calendar ID for the calendar you want to receive reminders from.') +""" + ) +) + + +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.", +) options = parser.parse_args() if not (options.zulip_email): - parser.error('You must specify --user') + parser.error("You must specify --user") zulip_client = zulip.init_from_options(options) + def get_credentials() -> client.Credentials: """Gets valid user credentials from storage. @@ -88,15 +98,14 @@ 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() return credentials except client.Error: - logging.exception('Error while trying to open the `google-credentials.json` file.') + logging.exception("Error while trying to open the `google-credentials.json` file.") except OSError: logging.error("Run the get-google-credentials script from this directory first.") @@ -106,11 +115,20 @@ def populate_events() -> Optional[None]: credentials = get_credentials() creds = credentials.authorize(httplib2.Http()) - service = discovery.build('calendar', 'v3', http=creds) + 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"]: @@ -156,10 +174,10 @@ def send_reminders() -> Optional[None]: key = (id, start) if key not in sent: if start.hour == 0 and start.minute == 0: - line = '%s is today.' % (summary,) + line = f"{summary} is today." else: - line = '%s starts at %s' % (summary, start.strftime('%H:%M')) - print('Sending reminder:', line) + line = "{} starts at {}".format(summary, start.strftime("%H:%M")) + print("Sending reminder:", line) messages.append(line) keys.add(key) @@ -167,18 +185,17 @@ def send_reminders() -> Optional[None]: return if len(messages) == 1: - message = 'Reminder: ' + messages[0] + message = "Reminder: " + messages[0] else: - message = 'Reminder:\n\n' + '\n'.join('* ' + m for m in messages) + 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 a03646b0f4..21cc4bd564 100755 --- a/zulip/integrations/hg/zulip_changegroup.py +++ b/zulip/integrations/hg/zulip_changegroup.py @@ -5,14 +5,19 @@ # This hook is called when changesets are pushed to the master repository (ie # `hg push`). See https://zulip.com/integrations for installation instructions. -import zulip import sys -from typing import Text -from mercurial import ui, repository as repo + +from mercurial import repository as repo +from mercurial import ui + +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: str +) -> str: """ Format the first line of the message, which contains summary information about the changeset and links to the changelog if a @@ -26,16 +31,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 = f"{revcount} commit{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: """ @@ -53,23 +60,25 @@ 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 = f"* [{one_liner}]({summary_url})" else: - summary = "* {summary}".format(summary=one_liner) + summary = f"* {one_liner}" commit_summaries.append(summary) 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: str +) -> 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", @@ -80,25 +89,27 @@ 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. - return ui.config('zulip', item) + return ui.config("zulip", item) except IndexError: - ui.warn("Zulip: Could not find required item {} in hg config.".format(item)) + ui.warn(f"Zulip: Could not find required item {item} in hg config.") sys.exit(1) -def hook(ui: ui, repo: repo, **kwargs: Text) -> None: + +def hook(ui: ui, repo: repo, **kwargs: str) -> None: """ Invoked by configuring a [hook] entry in .hg/hgrc. """ hooktype = kwargs["hooktype"] node = kwargs["node"] - ui.debug("Zulip: received {hooktype} event\n".format(hooktype=hooktype)) + ui.debug(f"Zulip: received {hooktype} event\n") if hooktype != "changegroup": - ui.warn("Zulip: {hooktype} not supported\n".format(hooktype=hooktype)) + ui.warn(f"Zulip: {hooktype} not supported\n") sys.exit(1) ctx = repo[node] @@ -112,14 +123,14 @@ def hook(ui: ui, repo: repo, **kwargs: Text) -> None: # Only send notifications on branches we are watching. watched_branches = [b.lower().strip() for b in branch_whitelist.split(",")] if branch.lower() not in watched_branches: - ui.debug("Zulip: ignoring event for {branch}\n".format(branch=branch)) + ui.debug(f"Zulip: ignoring event for {branch}\n") sys.exit(0) if branch_blacklist: # Don't send notifications for branches we've ignored. ignored_branches = [b.lower().strip() for b in branch_blacklist.split(",")] if branch.lower() in ignored_branches: - ui.debug("Zulip: ignoring event for {branch}\n".format(branch=branch)) + ui.debug(f"Zulip: ignoring event for {branch}\n") sys.exit(0) # The first and final commits in the changeset. diff --git a/zulip/integrations/jabber/jabber_mirror.py b/zulip/integrations/jabber/jabber_mirror.py index c14da4b02f..47cab5b593 100755 --- a/zulip/integrations/jabber/jabber_mirror.py +++ b/zulip/integrations/jabber/jabber_mirror.py @@ -1,17 +1,20 @@ #!/usr/bin/env python3 -import sys -import subprocess import os -import traceback import signal +import subprocess +import sys +import traceback from types import FrameType + from zulip import RandomExponentialBackoff + 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 93a1cc79e1..e70a52eda1 100755 --- a/zulip/integrations/jabber/jabber_mirror_backend.py +++ b/zulip/integrations/jabber/jabber_mirror_backend.py @@ -23,6 +23,11 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import logging +import optparse +import sys +from configparser import SafeConfigParser + # The following is a table showing which kinds of messages are handled by the # mirror in each mode: # @@ -37,32 +42,31 @@ # | other sender| x | | | # public mode +-------------+-----+----+--------+---- # | self sender | | | | -from typing import Dict, List, Set, Optional +from typing import Any, Callable, Dict, List, Optional, Set -import logging -import optparse - -from sleekxmpp import ClientXMPP, InvalidJID, JID +from sleekxmpp import JID, ClientXMPP, InvalidJID from sleekxmpp.stanza import Message as JabberMessage -from configparser import SafeConfigParser -import sys + import zulip from zulip import Client -from typing import Any, Callable __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 = '' + suffix = "" if not jid.username.endswith("-bot"): suffix = options.zulip_email_suffix - return "%s%s@%s" % (jid.username, suffix, options.zulip_domain) + return f"{jid.username}{suffix}@{options.zulip_domain}" + def zulip_to_jid(email: str, jabber_domain: str) -> JID: jid = JID(email, domain=jabber_domain) @@ -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: @@ -89,10 +94,10 @@ def __init__(self, jid: JID, password: str, rooms: List[str]) -> None: self.zulip = None self.use_ipv6 = False - self.register_plugin('xep_0045') # Jabber chatrooms - self.register_plugin('xep_0199') # XMPP Ping + self.register_plugin("xep_0045") # Jabber chatrooms + self.register_plugin("xep_0199") # XMPP Ping - def set_zulip_client(self, zulipToJabberClient: 'ZulipToJabberBot') -> None: + def set_zulip_client(self, zulipToJabberClient: "ZulipToJabberBot") -> None: self.zulipToJabber = zulipToJabberClient def session_start(self, event: Dict[str, Any]) -> None: @@ -107,7 +112,7 @@ def join_muc(self, room: str) -> None: logging.debug("Joining " + room) self.rooms.add(room) muc_jid = JID(local=room, domain=options.conference_domain) - xep0045 = self.plugin['xep_0045'] + xep0045 = self.plugin["xep_0045"] try: xep0045.joinMUC(muc_jid, self.nick, wait=True) except InvalidJID: @@ -132,7 +137,7 @@ def leave_muc(self, room: str) -> None: logging.debug("Leaving " + room) self.rooms.remove(room) muc_jid = JID(local=room, domain=options.conference_domain) - self.plugin['xep_0045'].leaveMUC(muc_jid, self.nick) + self.plugin["xep_0045"].leaveMUC(muc_jid, self.nick) def message(self, msg: JabberMessage) -> Any: try: @@ -147,29 +152,29 @@ def message(self, msg: JabberMessage) -> Any: logging.exception("Error forwarding Jabber => Zulip") def private(self, msg: JabberMessage) -> None: - if options.mode == 'public' or msg['thread'] == '\u1FFFE': + if options.mode == "public" or msg["thread"] == "\u1FFFE": return sender = jid_to_zulip(msg["from"]) 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": logging.error(str(ret)) def group(self, msg: JabberMessage) -> None: - if options.mode == 'personal' or msg["thread"] == '\u1FFFE': + if options.mode == "personal" or msg["thread"] == "\u1FFFE": return subject = msg["subject"] if len(subject) == 0: subject = "(no topic)" - stream = room_to_stream(msg['from'].local) + stream = room_to_stream(msg["from"].local) sender_nick = msg.get_mucnick() if not sender_nick: # Messages from the room itself have no nickname. We should not try @@ -178,24 +183,25 @@ def group(self, msg: JabberMessage) -> None: 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": logging.error(str(ret)) 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 == ''): - return JID(local=nick.replace(' ', ''), domain=self.boundjid.domain) + jid = self.plugin["xep_0045"].getJidProperty(room, nick, "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 @@ -205,163 +211,178 @@ def set_jabber_client(self, client: JabberToZulipBot) -> None: self.jabber = client def process_event(self, event: Dict[str, Any]) -> None: - if event['type'] == 'message': + if event["type"] == "message": message = event["message"] - if message['sender_email'] != self.client.email: + if message["sender_email"] != self.client.email: return try: - if message['type'] == 'stream': + if message["type"] == "stream": self.stream_message(message) - elif message['type'] == 'private': + elif message["type"] == "private": self.private_message(message) except Exception: logging.exception("Exception forwarding Zulip => Jabber") - elif event['type'] == 'subscription': + elif event["type"] == "subscription": self.process_subscription(event) def stream_message(self, msg: Dict[str, str]) -> None: - assert(self.jabber is not None) - stream = msg['display_recipient'] + assert self.jabber is not None + stream = msg["display_recipient"] if not stream.endswith("/xmpp"): return 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') - outgoing['thread'] = '\u1FFFE' + 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) - for recipient in msg['display_recipient']: + assert self.jabber is not None + for recipient in msg["display_recipient"]: if recipient["email"] == self.client.email: continue if not recipient["is_mirror_dummy"]: continue - recip_email = recipient['email'] + 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') - outgoing['thread'] = '\u1FFFE' + 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) - if event['op'] == 'add': - streams = [s['name'].lower() for s in event['subscriptions']] + 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")] for stream in streams: self.jabber.join_muc(stream_to_room(stream)) - if event['op'] == 'remove': - streams = [s['name'].lower() for s in event['subscriptions']] + if event["op"] == "remove": + streams = [s["name"].lower() for s in event["subscriptions"]] streams = [s for s in streams if s.endswith("/xmpp")] 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() if ret.get("result") != "success": logging.error(str(ret)) - sys.exit("Could not get initial list of Zulip %s" % (key,)) + sys.exit(f"Could not get initial list of Zulip {key}") return ret[key] - if options.mode == 'public': + if options.mode == "public": stream_infos = get_stream_infos("streams", zulipToJabber.client.get_streams) else: stream_infos = get_stream_infos("subscriptions", zulipToJabber.client.get_subscriptions) rooms = [] # type: List[str] for stream_info in stream_infos: - stream = stream_info['name'] + stream = stream_info["name"] if stream.endswith("/xmpp"): rooms.append(stream_to_room(stream)) return rooms + def config_error(msg: str) -> None: - sys.stderr.write("%s\n" % (msg,)) + sys.stderr.write(f"{msg}\n") sys.exit(2) -if __name__ == '__main__': + +if __name__ == "__main__": parser = optparse.OptionParser( - epilog='''Most general and Jabber configuration options may also be specified in the + 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', + "--mode", default=None, - action='store', + action="store", help='''Which mode to run in. Valid options are "personal" and "public". In "personal" mode, the mirror uses an individual users' credentials and mirrors 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', + "--zulip-email-suffix", default=None, - action='store', - help='''Add the specified suffix to the local part of email addresses constructed + action="store", + help="""Add the specified suffix to the local part of email addresses constructed 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( - '--jid', + "--jid", default=None, - action='store', + 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 @@ def config_error(msg: str) -> None: 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",): @@ -397,26 +422,30 @@ def config_error(msg: str) -> None: options.mode = "personal" if options.zulip_email_suffix is None: - options.zulip_email_suffix = '' + options.zulip_email_suffix = "" - if options.mode not in ('public', 'personal'): + if options.mode not in ("public", "personal"): 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] + options.zulip_domain = zulipToJabber.client.email.partition("@")[-1] try: jid = JID(options.jid) except InvalidJID as e: - config_error("Bad JID: %s: %s" % (options.jid, e.message)) + config_error(f"Bad JID: {options.jid}: {e.message}") if options.conference_domain is None: - options.conference_domain = "conference.%s" % (jid.domain,) + options.conference_domain = f"conference.{jid.domain}" xmpp = JabberToZulipBot(jid, options.jabber_password, get_rooms(zulipToJabber)) @@ -431,15 +460,16 @@ def config_error(msg: str) -> None: zulipToJabber.set_jabber_client(xmpp) xmpp.process(block=False) - if options.mode == 'public': - event_types = ['stream'] + if options.mode == "public": + event_types = ["stream"] else: - event_types = ['message', 'subscription'] + event_types = ["message", "subscription"] 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 22e88012c9..5526c6c9f0 100755 --- a/zulip/integrations/log2zulip/log2zulip +++ b/zulip/integrations/log2zulip/log2zulip @@ -5,8 +5,8 @@ import errno import os import platform import re -import sys import subprocess +import sys import tempfile import traceback @@ -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 @@ -25,11 +27,13 @@ except ImportError: import json sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) -import zulip from typing import List +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: @@ -40,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, - }) + content = "{} new errors{}:\n```\n{}\n```".format(count, extra, "\n".join(lines)) + zulip_client.send_message( + { + "type": "stream", + "to": "logs", + "subject": f"{file_name} on {platform.node()}", + "content": content, + } + ) + def process_lines(raw_lines: List[str], file_name: str) -> None: lines = [] @@ -64,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)) @@ -75,7 +84,7 @@ def process_logs() -> None: file_data = last_data.get(log_file, {}) if not os.path.exists(log_file): # If the file doesn't exist, log an error and then move on to the next file - print("Log file does not exist or could not stat log file: %s" % (log_file,)) + print(f"Log file does not exist or could not stat log file: {log_file}") continue length = int(subprocess.check_output(["wc", "-l", log_file]).split()[0]) if file_data.get("last") is None: @@ -86,14 +95,15 @@ def process_logs() -> None: # a log file ends up at the same line length as before # immediately after rotation, this tool won't notice. file_data["last"] = 1 - output = subprocess.check_output(["tail", "-n+%s" % (file_data["last"],), log_file]) - new_lines = output.decode('utf-8', errors='replace').split('\n')[:-1] + output = subprocess.check_output(["tail", "-n+{}".format(file_data["last"]), log_file]) + new_lines = output.decode("utf-8", errors="replace").split("\n")[:-1] if len(new_lines) > 0: process_lines(new_lines, log_file) file_data["last"] += len(new_lines) 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") @@ -114,7 +124,7 @@ if __name__ == "__main__": try: log_files = json.loads(open(args.control_path).read()) except (json.JSONDecodeError, OSError): - print("Could not load control data from %s" % (args.control_path,)) + print(f"Could not load control data from {args.control_path}") traceback.print_exc() sys.exit(1) process_logs() diff --git a/zulip/integrations/nagios/nagios-notify-zulip b/zulip/integrations/nagios/nagios-notify-zulip index 7c86bd3197..670d9bf79b 100755 --- a/zulip/integrations/nagios/nagios-notify-zulip +++ b/zulip/integrations/nagios/nagios-notify-zulip @@ -1,26 +1,27 @@ #!/usr/bin/env python3 import argparse -import zulip - from typing import Any, Dict, Text +import zulip + VERSION = "0.9" # Nagios passes the notification details as command line options. # In Nagios, "output" means "first line of output", and "long # output" means "other lines of output". parser = zulip.add_default_arguments(argparse.ArgumentParser()) # type: argparse.ArgumentParser -parser.add_argument('--output', default='') -parser.add_argument('--long-output', default='') -parser.add_argument('--stream', default='nagios') -parser.add_argument('--config', default='/etc/nagios3/zuliprc') -for opt in ('type', 'host', 'service', 'state'): - parser.add_argument('--' + opt) +parser.add_argument("--output", default="") +parser.add_argument("--long-output", default="") +parser.add_argument("--stream", default="nagios") +parser.add_argument("--config", default="/etc/nagios3/zuliprc") +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] +msg = dict(type="stream", to=opts.stream) # type: Dict[str, Any] # Set a subject based on the host or service in question. This enables # threaded discussion of multiple concurrent issues, and provides useful @@ -29,24 +30,24 @@ msg = dict(type='stream', to=opts.stream) # type: Dict[str, Any] # We send PROBLEM and RECOVERY messages to the same subject. if opts.service is None: # Host notification - thing = 'host' # type: Text - msg['subject'] = 'host %s' % (opts.host,) + thing = "host" # type: Text + msg["subject"] = f"host {opts.host}" else: # Service notification - thing = 'service' - msg['subject'] = 'service %s on %s' % (opts.service, opts.host) + thing = "service" + msg["subject"] = f"service {opts.service} on {opts.host}" -if len(msg['subject']) > 60: - msg['subject'] = msg['subject'][0:57].rstrip() + "..." +if len(msg["subject"]) > 60: + msg["subject"] = msg["subject"][0:57].rstrip() + "..." # e.g. **PROBLEM**: service is CRITICAL -msg['content'] = '**%s**: %s is %s' % (opts.type, thing, opts.state) +msg["content"] = f"**{opts.type}**: {thing} is {opts.state}" # The "long output" can contain newlines represented by "\n" escape sequences. # The Nagios mail command uses /usr/bin/printf "%b" to expand these. # We will be more conservative and handle just this one escape sequence. -output = (opts.output + '\n' + opts.long_output.replace(r'\n', '\n')).strip() # type: Text +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 6458d81c0c..62e4a33817 100755 --- a/zulip/integrations/openshift/post_deploy +++ b/zulip/integrations/openshift/post_deploy @@ -9,45 +9,55 @@ from typing import Dict sys.path.insert(0, os.path.dirname(__file__)) import zulip_openshift_config as config -VERSION = '0.1' + +VERSION = "0.1" if config.ZULIP_API_PATH is not None: sys.path.append(config.ZULIP_API_PATH) import zulip + 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: # Activation time - Deployment ID - Git Ref - Git SHA1 # 2017-01-07 15:40:30 -0500 - 9e2b7143 - master - b9ce57c - ACTIVE - dep = subprocess.check_output(['gear', 'deployments'], universal_newlines=True).splitlines()[1] - splits = dep.split(' - ') + 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']) + destination = config.deployment_notice_destination(deployment["branch"]) if destination is None: # No message should be sent 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 2de32c5e3d..13d0462f44 100755 --- a/zulip/integrations/openshift/zulip_openshift_config.py +++ b/zulip/integrations/openshift/zulip_openshift_config.py @@ -1,9 +1,9 @@ # https://github.com/python/mypy/issues/1141 -from typing import Dict, Text, Optional +from typing import Dict, Optional # Change these values to configure authentication for the plugin -ZULIP_USER = 'openshift-bot@example.com' -ZULIP_API_KEY = '0123456789abcdef0123456789abcdef' +ZULIP_USER = "openshift-bot@example.com" +ZULIP_API_KEY = "0123456789abcdef0123456789abcdef" # deployment_notice_destination() lets you customize where deployment notices # are sent to with the full power of a Python function. @@ -19,14 +19,14 @@ # * stream "deployments" # * topic "master" # 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,)) +def deployment_notice_destination(branch: str) -> Optional[Dict[str, str]]: + if branch in ["master", "test-post-receive"]: + return dict(stream="deployments", subject=f"{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,13 +39,19 @@ 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 f"Deployed commit `{commit_id}` ({branch}) in [{app_name}]({url})" + ## If properly installed, the Zulip API should be in your import ## path, but if not, set a custom path below ZULIP_API_PATH = None # type: Optional[str] # Set this to your Zulip server's API URI -ZULIP_SITE = 'https://zulip.example.com' +ZULIP_SITE = "https://zulip.example.com" diff --git a/zulip/integrations/perforce/zulip_change-commit.py b/zulip/integrations/perforce/zulip_change-commit.py index f029f68df7..ea77260045 100755 --- a/zulip/integrations/perforce/zulip_change-commit.py +++ b/zulip/integrations/perforce/zulip_change-commit.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -'''Zulip notification change-commit hook. +"""Zulip notification change-commit hook. In Perforce, The "change-commit" trigger is fired after a metadata has been created, files have been transferred, and the changelist committed to the depot @@ -12,11 +12,11 @@ For example: 1234 //depot/security/src/ -''' +""" import os -import sys import os.path +import sys import git_p4 @@ -24,33 +24,38 @@ sys.path.insert(0, os.path.dirname(__file__)) from typing import Any, Dict, Optional + import zulip_perforce_config as config if config.ZULIP_API_PATH is not None: sys.path.append(config.ZULIP_API_PATH) import zulip + 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 changeroot = sys.argv[2] # type: str except IndexError: - print("Wrong number of arguments.\n\n", end=' ', file=sys.stderr) + print("Wrong number of arguments.\n\n", end=" ", file=sys.stderr) print(__doc__, file=sys.stderr) sys.exit(-1) except ValueError: - print("First argument must be an integer.\n\n", end=' ', file=sys.stderr) + print("First argument must be an integer.\n\n", end=" ", file=sys.stderr) print(__doc__, file=sys.stderr) sys.exit(-1) 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 @@ -74,7 +79,7 @@ if p4web is not None: # linkify the change number - change = '[{change}]({p4web}/{change}?ac=10)'.format(p4web=p4web, change=change) + change = "[{change}]({p4web}/{change}?ac=10)".format(p4web=p4web, change=change) message = """**{user}** committed revision @{change} to `{path}`. @@ -82,10 +87,8 @@ {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 2e2aa2f965..da9b03b8b7 100644 --- a/zulip/integrations/perforce/zulip_perforce_config.py +++ b/zulip/integrations/perforce/zulip_perforce_config.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, Text +from typing import Dict, Optional # Change these values to configure authentication for the plugin ZULIP_USER = "p4-bot@example.com" @@ -28,8 +28,8 @@ # "master-plan" and "secret" subdirectories of //depot/ to: # * stream "depot_subdirectory-commits" # * subject "change_root" -def commit_notice_destination(path: Text, changelist: int) -> Optional[Dict[Text, Text]]: - dirs = path.split('/') +def commit_notice_destination(path: str, changelist: int) -> Optional[Dict[str, str]]: + dirs = path.split("/") if len(dirs) >= 4 and dirs[3] not in ("*", "..."): directory = dirs[3] else: @@ -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=f"{directory}-commits", 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 c387732f42..bbe6a1d080 100755 --- a/zulip/integrations/rss/rss-bot +++ b/zulip/integrations/rss/rss-bot @@ -3,23 +3,25 @@ # RSS integration for Zulip # +import argparse import calendar import errno import hashlib -from html.parser import HTMLParser import logging -import argparse import os import re import sys import time import urllib.parse -from typing import Dict, List, Tuple, Any +from html.parser import HTMLParser +from typing import Any, Dict, List, Tuple import feedparser + import zulip + VERSION = "0.9" # type: str -RSS_DATA_DIR = os.path.expanduser(os.path.join('~', '.cache', 'zulip-rss')) # type: str +RSS_DATA_DIR = os.path.expanduser(os.path.join("~", ".cache", "zulip-rss")) # type: str OLDNESS_THRESHOLD = 30 # type: int usage = """Usage: Send summaries of RSS entries for your favorite feeds to Zulip. @@ -46,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: @@ -85,11 +100,12 @@ def mkdir_p(path: str) -> None: else: raise + try: mkdir_p(opts.data_dir) except OSError: # We can't write to the logfile, so just print and give up. - print("Unable to store RSS data at %s." % (opts.data_dir,), file=sys.stderr) + print(f"Unable to store RSS data at {opts.data_dir}.", file=sys.stderr) exit(1) log_file = os.path.join(opts.data_dir, "rss-bot.log") # type: str @@ -104,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__() @@ -119,59 +137,72 @@ class MLStripper(HTMLParser): self.fed.append(data) def get_data(self) -> str: - return ''.join(self.fed) + 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) + 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 = "**[{}]({})**\n{}\n{}".format( + 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] + content = content.replace("$", "$$") + + 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,)) + log_error_and_exit(f"Unable to read feed file at {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 @@ -180,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 = {} @@ -190,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 @@ -207,7 +245,7 @@ for feed_url in feed_urls: response = send_zulip(entry, feed_name) # type: Dict[str, Any] if response["result"] != "success": - logger.error("Error processing %s" % (feed_url,)) + logger.error(f"Error processing {feed_url}") logger.error(str(response)) if first_message: # This is probably some fundamental problem like the stream not diff --git a/zulip/integrations/svn/post-commit b/zulip/integrations/svn/post-commit index e88f760932..b58b410f03 100755 --- a/zulip/integrations/svn/post-commit +++ b/zulip/integrations/svn/post-commit @@ -10,24 +10,28 @@ # /srv/svn/carols 1843 import os -import sys import os.path -import pysvn +import sys from typing import Any, Dict, Optional, Text, Tuple +import pysvn + sys.path.insert(0, os.path.dirname(__file__)) import zulip_svn_config as config + VERSION = "0.9" if config.ZULIP_API_PATH is not None: sys.path.append(config.ZULIP_API_PATH) import zulip + 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] @@ -35,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 4c9d94d171..f851ae2fb5 100644 --- a/zulip/integrations/svn/zulip_svn_config.py +++ b/zulip/integrations/svn/zulip_svn_config.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, Text +from typing import Dict, Optional # Change these values to configure authentication for the plugin ZULIP_USER = "svn-bot@example.com" @@ -18,15 +18,15 @@ # and "my-super-secret-repository" repos to # * stream "commits" # * topic "branch_name" -def commit_notice_destination(path: Text, commit: Text) -> Optional[Dict[Text, Text]]: - repo = path.split('/')[-1] +def commit_notice_destination(path: str, commit: str) -> Optional[Dict[str, str]]: + 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=f"{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 646c0aebe4..bb16dd258f 100644 --- a/zulip/integrations/trac/zulip_trac.py +++ b/zulip/integrations/trac/zulip_trac.py @@ -11,12 +11,15 @@ # You may then need to restart trac (or restart Apache) for the bot # (or changes to the bot) to actually be loaded by trac. +import os.path +import sys + from trac.core import Component, implements from trac.ticket import ITicketChangeListener -import sys -import os.path + sys.path.insert(0, os.path.dirname(__file__)) import zulip_trac_config as config + VERSION = "0.9" from typing import Any, Dict @@ -25,50 +28,65 @@ sys.path.append(config.ZULIP_API_PATH) import zulip + 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) + return f"[{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) + return truncate("#{}: {}".format(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 = "{} created {} in component **{}**, priority **{}**:\n".format( + 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"),) + content += "**{}**\n".format(ticket.values.get("summary")) if ticket.values.get("description") != "": - content += "%s" % (markdown_block(ticket.values.get("description")),) + content += "{}".format(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 @@ -80,28 +98,32 @@ def ticket_changed(self, ticket: Any, comment: str, author: str, old_values: Dic ): return - content = "%s updated %s" % (author, markdown_ticket_url(ticket)) + content = f"{author} updated {markdown_ticket_url(ticket)}" if comment: - content += ' with comment: %s\n\n' % (markdown_block(comment),) + content += f" with comment: {markdown_block(comment)}\n\n" else: content += ":\n\n" 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 {} from {}\n\nto {}".format( + 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))) + field_changes.append(f"{key}: => **{ticket.values.get(key)}**") elif ticket.values.get(key) == "": - field_changes.append('%s: **%s** => ""' % (key, old_values.get(key))) + field_changes.append(f'{key}: **{old_values.get(key)}** => ""') else: - field_changes.append('%s: **%s** => **%s**' % (key, old_values.get(key), - ticket.values.get(key))) + field_changes.append( + f"{key}: **{old_values.get(key)}** => **{ticket.values.get(key)}**" + ) content += ", ".join(field_changes) send_update(ticket, content) def ticket_deleted(self, ticket: Any) -> None: """Called when a ticket is deleted.""" - content = "%s was deleted." % (markdown_ticket_url(ticket, heading="Ticket"),) + content = "{} was deleted.".format(markdown_ticket_url(ticket, heading="Ticket")) send_update(ticket, content) diff --git a/zulip/integrations/trello/zulip_trello.py b/zulip/integrations/trello/zulip_trello.py index b0594da634..eb1e7ddee2 100755 --- a/zulip/integrations/trello/zulip_trello.py +++ b/zulip/integrations/trello/zulip_trello.py @@ -3,8 +3,8 @@ # An easy Trello integration for Zulip. -import sys import argparse +import sys try: import requests @@ -13,6 +13,7 @@ print("http://docs.python-requests.org/en/master/user/install/") sys.exit(1) + def get_model_id(options): """get_model_id @@ -24,27 +25,22 @@ def get_model_id(options): """ - trello_api_url = 'https://api.trello.com/1/board/{}'.format( - options.trello_board_id - ) + trello_api_url = f"https://api.trello.com/1/board/{options.trello_board_id}" params = { - 'key': options.trello_api_key, - 'token': options.trello_token, + "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') + print("Error: Can't get the idModel. Please check the configuration") sys.exit(1) board_info_json = trello_response.json() - return board_info_json['id'] + return board_info_json["id"] def get_webhook_id(options, id_model): @@ -59,30 +55,28 @@ def get_webhook_id(options, id_model): """ - trello_api_url = 'https://api.trello.com/1/webhooks/' + trello_api_url = "https://api.trello.com/1/webhooks/" data = { - 'key': options.trello_api_key, - 'token': options.trello_token, - 'description': 'Webhook for Zulip integration (From Trello {} to Zulip)'.format( + "key": options.trello_api_key, + "token": options.trello_token, + "description": "Webhook for Zulip integration (From Trello {} to Zulip)".format( options.trello_board_name, ), - 'callbackURL': options.zulip_webhook_url, - 'idModel': id_model + "callbackURL": options.zulip_webhook_url, + "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) + print("Error: Can't create the Webhook:", trello_response.text) sys.exit(1) webhook_info_json = trello_response.json() - return webhook_info_json['id'] + return webhook_info_json["id"] + def create_webhook(options): """create_webhook @@ -94,20 +88,24 @@ def create_webhook(options): """ # first, we need to get the idModel - print('Getting Trello idModel for the {} board...'.format(options.trello_board_name)) + print(f"Getting Trello idModel for the {options.trello_board_name} board...") id_model = get_model_id(options) if id_model: - print('Success! The idModel is', id_model) + print("Success! The idModel is", id_model) id_webhook = get_webhook_id(options, id_model) if id_webhook: - print('Success! The webhook ID is', 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 @@ def main(): """ 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__': + +if __name__ == "__main__": main() diff --git a/zulip/integrations/twitter/twitter-bot b/zulip/integrations/twitter/twitter-bot index 21f094b36f..15823f488d 100755 --- a/zulip/integrations/twitter/twitter-bot +++ b/zulip/integrations/twitter/twitter-bot @@ -2,12 +2,13 @@ # Twitter integration for Zulip +import argparse import os import sys -import argparse -from configparser import ConfigParser, NoSectionError, NoOptionError +from configparser import ConfigParser, NoOptionError, NoSectionError import zulip + VERSION = "0.9" CONFIGFILE = os.path.expanduser("~/.zulip_twitterrc") INSTRUCTIONS = r""" @@ -66,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: + 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() @@ -105,15 +103,15 @@ if opts.instructions: sys.exit() if all([opts.search_terms, opts.twitter_name]): - parser.error('You must only specify either a search term or a username.') + parser.error("You must only specify either a search term or a username.") if opts.search_terms: - client_type = 'ZulipTwitterSearch/' + client_type = "ZulipTwitterSearch/" CONFIGFILE_INTERNAL = os.path.expanduser("~/.zulip_twitterrc_fetchsearch") elif opts.twitter_name: - client_type = 'ZulipTwitter/' + client_type = "ZulipTwitter/" CONFIGFILE_INTERNAL = os.path.expanduser("~/.zulip_twitteruserrc_fetchuser") else: - parser.error('You must either specify a search term or a username.') + parser.error("You must either specify a search term or a username.") try: config = ConfigParser() @@ -121,10 +119,10 @@ try: config_internal = ConfigParser() config_internal.read(CONFIGFILE_INTERNAL) - consumer_key = config.get('twitter', 'consumer_key') - consumer_secret = config.get('twitter', 'consumer_secret') - access_token_key = config.get('twitter', 'access_token_key') - access_token_secret = config.get('twitter', 'access_token_secret') + consumer_key = config.get("twitter", "consumer_key") + consumer_secret = config.get("twitter", "consumer_secret") + access_token_key = config.get("twitter", "access_token_key") + access_token_secret = config.get("twitter", "access_token_secret") except (NoSectionError, NoOptionError): parser.error("Please provide a ~/.zulip_twitterrc") @@ -132,35 +130,39 @@ if not all([consumer_key, consumer_secret, access_token_key, access_token_secret parser.error("Please provide a ~/.zulip_twitterrc") try: - since_id = config_internal.getint('twitter', 'since_id') + since_id = config_internal.getint("twitter", "since_id") except (NoOptionError, NoSectionError): since_id = 0 try: - previous_twitter_name = config_internal.get('twitter', 'twitter_name') + previous_twitter_name = config_internal.get("twitter", "twitter_name") except (NoOptionError, NoSectionError): - previous_twitter_name = '' + previous_twitter_name = "" try: - previous_search_terms = config_internal.get('twitter', 'search_terms') + previous_search_terms = config_internal.get("twitter", "search_terms") except (NoOptionError, NoSectionError): - previous_search_terms = '' + previous_search_terms = "" try: import twitter 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(",")) @@ -189,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: @@ -200,8 +202,8 @@ for status in statuses[::-1][:opts.limit_tweets]: continue # Continue with the loop for the next tweet # https://twitter.com/eatevilpenguins/status/309995853408530432 - composed = "%s (%s)" % (status.user.name, status.user.screen_name) - url = "https://twitter.com/%s/status/%s" % (status.user.screen_name, status.id) + composed = f"{status.user.name} ({status.user.screen_name})" + url = f"https://twitter.com/{status.user.screen_name}/status/{status.id}" # This contains all strings that could have caused the tweet to match our query. text_to_check = [status.text, status.user.screen_name] text_to_check.extend(url.expanded_url for url in status.urls) @@ -236,26 +238,21 @@ 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) - if ret['result'] == 'error': + if ret["result"] == "error": # If sending failed (e.g. no such stream), abort and retry next time - print("Error sending message to zulip: %s" % ret['msg']) + print("Error sending message to zulip: %s" % ret["msg"]) break else: since_id = status.id -if 'twitter' not in config_internal.sections(): - config_internal.add_section('twitter') -config_internal.set('twitter', 'since_id', str(since_id)) -config_internal.set('twitter', 'search_terms', str(opts.search_terms)) -config_internal.set('twitter', 'twitter_name', str(opts.twitter_name)) +if "twitter" not in config_internal.sections(): + config_internal.add_section("twitter") +config_internal.set("twitter", "since_id", str(since_id)) +config_internal.set("twitter", "search_terms", str(opts.search_terms)) +config_internal.set("twitter", "twitter_name", str(opts.twitter_name)) write_config(config_internal, CONFIGFILE_INTERNAL) diff --git a/zulip/integrations/zephyr/check-mirroring b/zulip/integrations/zephyr/check-mirroring index 6a61d166fe..33d63c6052 100755 --- a/zulip/integrations/zephyr/check-mirroring +++ b/zulip/integrations/zephyr/check-mirroring @@ -1,47 +1,37 @@ #!/usr/bin/env python3 -import sys -import time +import hashlib +import logging import optparse import random -import logging import subprocess -import hashlib +import sys +import time +from typing import Dict, List, Set, Tuple + import zephyr -import zulip -from typing import Dict, List, Set, Tuple +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' +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) @@ -74,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, @@ -90,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": @@ -98,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: @@ -115,14 +112,15 @@ 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"]) - if 'error' in res['result']: + if "error" in res["result"]: logging.error("Error subscribing to Zulips!") - logging.error(res['msg']) + logging.error(res["msg"]) print_status_and_exit(1) - queue_id, last_event_id = (res['queue_id'], res['last_event_id']) + queue_id, last_event_id = (res["queue_id"], res["last_event_id"]) except Exception: logger.exception("Unexpected error subscribing to Zulips") print_status_and_exit(1) @@ -131,9 +129,9 @@ except Exception: zephyr_subs_to_add = [] for (stream, test) in test_streams: if stream == "message": - zephyr_subs_to_add.append((stream, 'personal', mit_user)) + zephyr_subs_to_add.append((stream, "personal", mit_user)) else: - zephyr_subs_to_add.append((stream, '*', '*')) + zephyr_subs_to_add.append((stream, "*", "*")) actually_subscribed = False for tries in range(10): @@ -145,7 +143,7 @@ for tries in range(10): missing = 0 for elt in zephyr_subs_to_add: if elt not in zephyr_subs: - logging.error("Failed to subscribe to %s" % (elt,)) + logging.error(f"Failed to subscribe to {elt}") missing += 1 if missing == 0: actually_subscribed = True @@ -163,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: @@ -170,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) @@ -195,6 +197,7 @@ def receive_zephyrs() -> None: continue notices.append(notice) + logger.info("Starting sending messages!") # Send zephyrs zsig = "Timothy Good Abbott" @@ -211,12 +214,13 @@ 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(f"Replaced key {key} with {new_key} due to Zephyr server failure.") receive_zephyrs() receive_zephyrs() @@ -225,18 +229,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!") @@ -253,17 +261,19 @@ logger.info("Starting receiving messages!") # receive zulips res = zulip_client.get_events(queue_id=queue_id, last_event_id=last_event_id) -if 'error' in res['result']: +if "error" in res["result"]: logging.error("Error receiving Zulips!") - logging.error(res['msg']) + logging.error(res["msg"]) print_status_and_exit(1) -messages = [event['message'] for event in res['events']] +messages = [event["message"] for event in res["events"]] logger.info("Finished receiving Zulip messages!") 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 @@ -280,10 +290,11 @@ 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] -z_contents = [notice.message.split('\0')[1] for notice in notices] +z_contents = [notice.message.split("\0")[1] for notice in notices] (h_key_counts, h_missing_z, h_missing_h, h_duplicates, h_success) = process_keys(h_contents) (z_key_counts, z_missing_z, z_missing_h, z_duplicates, z_success) = process_keys(z_contents) @@ -301,12 +312,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:") @@ -321,10 +336,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 c633345a26..52688d3f15 100755 --- a/zulip/integrations/zephyr/process_ccache +++ b/zulip/integrations/zephyr/process_ccache @@ -1,33 +1,35 @@ #!/usr/bin/env python3 +import base64 import os -import sys import subprocess -import base64 +import sys short_user = sys.argv[1] api_key = sys.argv[2] ccache_data_encoded = sys.argv[3] # Update the Kerberos ticket cache file -program_name = "zmirror-%s" % (short_user,) -with open("/home/zulip/ccache/%s" % (program_name,), "wb") as f: +program_name = f"zmirror-{short_user}" +with open(f"/home/zulip/ccache/{program_name}", "wb") as f: f.write(base64.b64decode(ccache_data_encoded)) # Setup API key -api_key_path = "/home/zulip/api-keys/%s" % (program_name,) +api_key_path = f"/home/zulip/api-keys/{program_name}" open(api_key_path, "w").write(api_key + "\n") # Setup supervisord configuration -supervisor_path = "/etc/supervisor/conf.d/zulip/%s.conf" % (program_name,) +supervisor_path = f"/etc/supervisor/conf.d/zulip/{program_name}.conf" template = os.path.join(os.path.dirname(__file__), "zmirror_private.conf.template") template_data = open(template).read() -session_path = "/home/zulip/zephyr_sessions/%s" % (program_name,) +session_path = f"/home/zulip/zephyr_sessions/{program_name}" # Preserve mail zephyrs forwarding setting across rewriting the config file 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 053c65592a..f8c8856255 100755 --- a/zulip/integrations/zephyr/sync-public-streams +++ b/zulip/integrations/zephyr/sync-public-streams @@ -1,14 +1,15 @@ #!/usr/bin/env python3 -import sys -import os -import logging import argparse import json +import logging +import os +import sys import unicodedata -sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'api')) +sys.path.append(os.path.join(os.path.dirname(__file__), "..", "api")) import zulip + def write_public_streams() -> None: public_streams = set() @@ -16,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 @@ -29,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__) @@ -82,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 e4b4b300ee..d4450b40b7 100755 --- a/zulip/integrations/zephyr/zephyr_mirror.py +++ b/zulip/integrations/zephyr/zephyr_mirror.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -import sys -import subprocess import os -import traceback import signal +import subprocess +import sys +import traceback sys.path[:0] = [os.path.dirname(__file__)] from zephyr_mirror_backend import parse_args @@ -13,11 +13,13 @@ from types import FrameType + 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 @@ -35,12 +37,14 @@ def die(signal: int, frame: FrameType) -> None: 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,)]) + subprocess.call(args + [f"--shard={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 0395984969..2d42335160 100755 --- a/zulip/integrations/zephyr/zephyr_mirror_backend.py +++ b/zulip/integrations/zephyr/zephyr_mirror_backend.py @@ -1,21 +1,20 @@ #!/usr/bin/env python3 -from typing import Any, Dict, IO, List, NoReturn, Optional, Set, Tuple, Union -from types import FrameType - -import sys +import hashlib import json -import re -import time -import subprocess +import logging import optparse import os -import textwrap +import re +import select import signal -import logging -import hashlib +import subprocess +import sys import tempfile -import select +import textwrap +import time +from types import FrameType +from typing import IO, Any, Dict, List, NoReturn, Optional, Set, Tuple, Union from typing_extensions import Literal, TypedDict @@ -23,12 +22,16 @@ 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("@") @@ -36,23 +39,25 @@ def to_zulip_username(zephyr_username: str) -> str: (user, realm) = (zephyr_username, "ATHENA.MIT.EDU") if realm.upper() == "ATHENA.MIT.EDU": # Hack to make ctl's fake username setup work :) - if user.lower() == 'golem': - user = 'ctl' + if user.lower() == "golem": + user = "ctl" 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: # Hack to make ctl's fake username setup work :) - if user.lower() == 'ctl': - user = 'golem' + if user.lower() == "ctl": + user = "golem" return user.lower() + "@ATHENA.MIT.EDU" - match_user = re.match(r'([a-zA-Z0-9_]+)\|(.+)', user) + match_user = re.match(r"([a-zA-Z0-9_]+)\|(.+)", user) if not match_user: - raise Exception("Could not parse Zephyr realm for cross-realm user %s" % (zulip_username,)) + raise Exception(f"Could not parse Zephyr realm for cross-realm user {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 @@ -71,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: @@ -79,15 +85,14 @@ 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 ( line == "" or previous_line == "" - or re.match(r'^\W', line, flags=re.UNICODE) + or re.match(r"^\W", line, flags=re.UNICODE) or different_paragraph(previous_line, line) ): # Use 2 newlines to separate sections so that we @@ -100,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 @@ -110,48 +116,54 @@ class ZephyrDict(TypedDict, total=False): content: str zsig: str + def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]: message: Dict[str, Any] message = {} if options.forward_class_messages: message["forged"] = "yes" - message['type'] = zeph['type'] - message['time'] = zeph['time'] - message['sender'] = to_zulip_username(zeph['sender']) + message["type"] = zeph["type"] + message["time"] = zeph["time"] + message["sender"] = to_zulip_username(zeph["sender"]) if "subject" in zeph: # Truncate the subject to the current limit in Zulip. No # need to do this for stream names, since we're only # subscribed to valid stream names. message["subject"] = zeph["subject"][:60] - if zeph['type'] == 'stream': + if zeph["type"] == "stream": # Forward messages sent to -c foo -i bar to stream bar subject "instance" if zeph["stream"] == "message": - message['to'] = zeph['subject'].lower() - message['subject'] = "instance %s" % (zeph['subject'],) + message["to"] = zeph["subject"].lower() + message["subject"] = "instance {}".format(zeph["subject"]) elif zeph["stream"] == "tabbott-test5": - message['to'] = zeph['subject'].lower() - message['subject'] = "test instance %s" % (zeph['subject'],) + message["to"] = zeph["subject"].lower() + message["subject"] = "test instance {}".format(zeph["subject"]) else: message["to"] = zeph["stream"] else: message["to"] = zeph["recipient"] - message['content'] = unwrap_lines(zeph['content']) + message["content"] = unwrap_lines(zeph["content"]) if options.test_mode and options.site == DEFAULT_SITE: - logger.debug("Message is: %s" % (str(message),)) - return {'result': "success"} + logger.debug(f"Message is: {str(message)}") + return {"result": "success"} 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) @@ -162,7 +174,7 @@ def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None: # retrying the next time the bot checks its subscriptions are # up to date. logger.exception("Error subscribing to streams (will retry automatically):") - logger.warning("Streams were: %s" % ([cls for cls, instance, recipient in subs],)) + logger.warning(f"Streams were: {[cls for cls, instance, recipient in subs]}") return try: actual_zephyr_subs = [cls for (cls, _, _) in zephyr._z.getSubscriptions()] @@ -174,7 +186,7 @@ def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None: return for (cls, instance, recipient) in subs: if cls not in actual_zephyr_subs: - logger.error("Zephyr failed to subscribe us to %s; will retry" % (cls,)) + logger.error(f"Zephyr failed to subscribe us to {cls}; will retry") try: # We'll retry automatically when we next check for # streams to subscribe to (within 15 seconds), but @@ -187,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) @@ -199,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: @@ -212,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: @@ -220,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...") @@ -245,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() @@ -288,24 +306,31 @@ 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) if ( - notice_format == 'New transaction [$1] entered in $2\nFrom: $3 ($5)\nSubject: $4' - or notice_format == 'New transaction [$1] entered in $2\nFrom: $3\nSubject: $4' + notice_format == "New transaction [$1] entered in $2\nFrom: $3 ($5)\nSubject: $4" + or notice_format == "New transaction [$1] entered in $2\nFrom: $3\nSubject: $4" ): # Logic based off of owl_zephyr_get_message in barnowl - fields = body.split('\x00') + 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]) + body = "New transaction [{}] entered in {}\nFrom: {} ({})\nSubject: {}".format( + 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', '') + 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")) @@ -316,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: @@ -338,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) @@ -383,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(f"Skipping ... {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!") @@ -402,51 +437,57 @@ 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 + zeph["type"] = "private" + zeph["recipient"] = huddle_recipients elif is_personal: assert notice.recipient is not None - zeph['type'] = 'private' - zeph['recipient'] = to_zulip_username(notice.recipient) + zeph["type"] = "private" + zeph["recipient"] = to_zulip_username(notice.recipient) else: - zeph['type'] = 'stream' - zeph['stream'] = zephyr_class + zeph["type"] = "stream" + zeph["stream"] = zephyr_class if notice.instance.strip() != "": - zeph['subject'] = notice.instance + zeph["subject"] = notice.instance else: - zeph["subject"] = '(instance "%s")' % (notice.instance,) + zeph["subject"] = f'(instance "{notice.instance}")' # Add instances in for instanced personals if is_personal: if notice.cls.lower() != "message" and notice.instance.lower != "personal": - heading = "[-c %s -i %s]\n" % (notice.cls, notice.instance) + heading = f"[-c {notice.cls} -i {notice.instance}]\n" elif notice.cls.lower() != "message": - heading = "[-c %s]\n" % (notice.cls,) + heading = f"[-c {notice.cls}]\n" elif notice.instance.lower() != "personal": - heading = "[-i %s]\n" % (notice.instance,) + heading = f"[-i {notice.instance}]\n" else: heading = "" zeph["content"] = heading + zeph["content"] - logger.info("Received a message on %s/%s from %s..." % - (zephyr_class, notice.instance, notice.sender)) + logger.info(f"Received a message on {zephyr_class}/{notice.instance} from {notice.sender}...") if log is not None: - log.write(json.dumps(zeph) + '\n') + log.write(json.dumps(zeph) + "\n") log.flush() if os.fork() == 0: @@ -456,17 +497,19 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None: try: res = send_zulip(zeph) if res.get("result") != "success": - logger.error("Error relaying zephyr:\n%s\n%s" % (zeph, res)) + logger.error(f"Error relaying zephyr:\n{zeph}\n{res}") except Exception: logger.exception("Error relaying zephyr:") 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(): @@ -482,6 +525,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(): @@ -498,6 +542,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(): @@ -513,6 +558,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") @@ -543,9 +589,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:") @@ -554,60 +601,80 @@ def zephyr_to_zulip(options: optparse.Values) -> None: logger.info("Successfully initialized; Starting receive loop.") if options.resend_log_path is not None: - with open(options.resend_log_path, 'a') as log: + with open(options.resend_log_path, "a") as log: process_loop(log) 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 '{}' printed the following warning:".format(" ".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." @@ -615,18 +682,26 @@ 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": + if message["type"] == "stream" and zulip_account_email == "ctl@mit.edu": zwrite_args.extend(["-S", "ctl"]) - if message['type'] == "stream": + if message["type"] == "stream": zephyr_class = message["display_recipient"] instance = message["subject"] @@ -635,9 +710,8 @@ def forward_to_zephyr(message: Dict[str, Any]) -> None: # 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 == f"instance {zephyr_class}" or instance == "test instance {}".format( + zephyr_class, ): # Forward messages to e.g. -c -i white-magic back from the # place we forward them to @@ -648,12 +722,12 @@ def forward_to_zephyr(message: Dict[str, Any]) -> None: instance = zephyr_class zephyr_class = "message" zwrite_args.extend(["-c", zephyr_class, "-i", instance]) - logger.info("Forwarding message to class %s, instance %s" % (zephyr_class, instance)) - elif message['type'] == "private": - if len(message['display_recipient']) == 1: + logger.info(f"Forwarding message to class {zephyr_class}, instance {instance}") + elif message["type"] == "private": + if len(message["display_recipient"]) == 1: recipient = to_zephyr_username(message["display_recipient"][0]["email"]) recipients = [recipient] - elif len(message['display_recipient']) == 2: + elif len(message["display_recipient"]) == 2: recipient = "" for r in message["display_recipient"]: if r["email"].lower() != zulip_account_email.lower(): @@ -664,15 +738,18 @@ def forward_to_zephyr(message: Dict[str, Any]) -> None: 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"]] - logger.info("Forwarding message to %s" % (recipients,)) + recipients = [ + to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "") + for user in message["display_recipient"] + ] + logger.info(f"Forwarding message to {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 \ @@ -680,7 +757,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 @@ -688,22 +767,24 @@ class and your mirroring bot does not have access to the relevant \ zwrite_args.extend(["-O", "crypt"]) if options.test_mode: - logger.debug("Would have forwarded: %s\n%s" % - (zwrite_args, wrapped_content)) + logger.debug(f"Would have forwarded: {zwrite_args}\n{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 ") @@ -715,7 +796,8 @@ class and your mirroring bot does not have access to the relevant \ 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, \ @@ -723,13 +805,16 @@ class and your mirroring bot does not have access to the relevant \ 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) \ @@ -737,20 +822,22 @@ class and your mirroring bot does not have access to the relevant \ %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"] ] ) ): @@ -759,8 +846,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: {} < {}".format(message["timestamp"], timestamp_now) + ) return try: forward_to_zephyr(message) @@ -769,6 +857,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.") @@ -780,6 +869,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 @@ -788,12 +878,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() @@ -806,7 +897,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 @@ -827,10 +925,12 @@ 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"],)) + logger.error("Error subscribing to streams:\n{}".format(res["msg"])) return already = res.get("already_subscribed") @@ -838,11 +938,19 @@ def add_zulip_subscriptions(verbose: bool) -> None: unauthorized = res.get("unauthorized") if verbose: if already is not None and len(already) > 0: - logger.info("\nAlready subscribed to: %s" % (", ".join(list(already.values())[0]),)) + logger.info( + "\nAlready subscribed to: {}".format(", ".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: {}".format(", ".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 @@ -851,11 +959,19 @@ def add_zulip_subscriptions(verbose: bool) -> None: 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 {}".format(", ".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 @@ -864,25 +980,39 @@ def add_zulip_subscriptions(verbose: bool) -> None: 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: if reason != "": - logger.info(" [%s,%s,%s] (%s)" % (cls, instance, recipient, reason)) + logger.info(f" [{cls},{instance},{recipient}] ({reason})") else: - logger.info(" [%s,%s,%s]" % (cls, instance, recipient)) + logger.info(f" [{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") @@ -902,15 +1032,16 @@ def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]: recipient = recipient.replace("%me%", options.user) if not valid_stream_name(cls): if verbose: - logger.error("Skipping subscription to unsupported class name: [%s]" % (line,)) + logger.error(f"Skipping subscription to unsupported class name: [{line}]") continue except Exception: if verbose: - logger.error("Couldn't parse ~/.zephyr.subs line: [%s]" % (line,)) + logger.error(f"Couldn't parse ~/.zephyr.subs line: [{line}]") continue 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 @@ -920,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=f"zulip-log.{options.user}.", delete=False) log_file = f.name # Close the file descriptor, since the logging system will # reopen it anyway. @@ -936,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" @@ -950,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) @@ -1048,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 @@ -1071,10 +1184,18 @@ def die_gracefully(signal: int, frame: FrameType) -> None: 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 @@ -1087,12 +1208,14 @@ def die_gracefully(signal: int, frame: FrameType) -> None: 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() @@ -1107,20 +1230,22 @@ def die_gracefully(signal: int, frame: FrameType) -> None: pgrep_query = "python.*zephyr_mirror" if options.shard is not None: # sharded class mirror - pgrep_query = "%s.*--shard=%s" % (pgrep_query, options.shard) + pgrep_query = f"{pgrep_query}.*--shard={options.shard}" 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) + pgrep_query = f"{pgrep_query}.*--user={options.user}" + 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(): continue # Another copy of zephyr_mirror.py! Kill it. - logger.info("Killing duplicate zephyr_mirror process %s" % (pid,)) + logger.info(f"Killing duplicate zephyr_mirror process {pid}") try: os.kill(pid, signal.SIGINT) except OSError: @@ -1136,7 +1261,7 @@ def die_gracefully(signal: int, frame: FrameType) -> None: options.forward_mail_zephyrs = subscribed_to_mail_messages() if options.session_path is None: - options.session_path = "/var/tmp/%s" % (options.user,) + options.session_path = f"/var/tmp/{options.user}" if options.forward_from_zulip: child_pid = os.fork() # type: Optional[int] @@ -1150,9 +1275,10 @@ def die_gracefully(signal: int, frame: FrameType) -> None: CURRENT_STATE = States.ZephyrToZulip import zephyr + logger_name = "zephyr=>zulip" if options.shard is not None: - logger_name += "(%s)" % (options.shard,) + logger_name += f"({options.shard})" configure_logger(logger, logger_name) # Have the kernel reap children for when we fork off processes to send Zulips signal.signal(signal.SIGCHLD, signal.SIG_IGN) diff --git a/zulip/setup.py b/zulip/setup.py index d12265ee43..5898e0de20 100755 --- a/zulip/setup.py +++ b/zulip/setup.py @@ -1,94 +1,100 @@ #!/usr/bin/env python3 -from typing import Any, Dict, Generator, List, Tuple - +import itertools import os import sys - -import itertools +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 = version_line.split('=')[-1].strip().replace('"', '') + 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', + name="zulip", version=version(), - description='Bindings for the Zulip message API', + description="Bindings for the Zulip message API", long_description=long_description, long_description_content_type="text/markdown", - author='Zulip Open Source Project', - author_email='zulip-devel@googlegroups.com', + author="Zulip Open Source Project", + author_email="zulip-devel@googlegroups.com", classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Topic :: Communications :: Chat', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Topic :: Communications :: Chat", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", ], - python_requires='>=3.6', - url='https://www.zulip.org/', + python_requires=">=3.6", + url="https://www.zulip.org/", project_urls={ "Source": "https://github.com/zulip/python-zulip-api/", "Documentation": "https://zulip.com/api", }, - data_files=list(recur_expand('share/zulip', 'integrations')), + data_files=list(recur_expand("share/zulip", "integrations")), include_package_data=True, entry_points={ - 'console_scripts': [ - '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' + "console_scripts": [ + "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", ], }, - package_data={'zulip': ["py.typed"]}, + 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 setup, find_packages + from setuptools import find_packages, setup + package_info.update(setuptools_info) - package_info['packages'] = find_packages(exclude=['tests']) + package_info["packages"] = find_packages(exclude=["tests"]) except ImportError: from distutils.core import setup from distutils.version import LooseVersion + # 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) - package_info['packages'] = ['zulip'] + package_info["packages"] = ["zulip"] setup(**package_info) diff --git a/zulip/tests/test_default_arguments.py b/zulip/tests/test_default_arguments.py index 15259f8a60..e79f795281 100755 --- a/zulip/tests/test_default_arguments.py +++ b/zulip/tests/test_default_arguments.py @@ -1,43 +1,51 @@ #!/usr/bin/env python3 import argparse -import os import io +import os import unittest -import zulip - from unittest import TestCase -from zulip import ZulipError from unittest.mock import patch -class TestDefaultArguments(TestCase): +import zulip +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: - with patch('sys.stderr', new=io.StringIO()) as mock_stderr: - parser.parse_args(['invalid argument']) + with patch("sys.stderr", new=io.StringIO()) as mock_stderr: + 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) + @patch("os.path.exists", return_value=False) def test_config_path_with_tilde(self, mock_os_path_exists: bool) -> None: parser = zulip.add_default_arguments(argparse.ArgumentParser(usage="lorem ipsum")) - test_path = '~/zuliprc' - args = parser.parse_args(['--config-file', test_path]) + test_path = "~/zuliprc" + args = parser.parse_args(["--config-file", test_path]) 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__': +if __name__ == "__main__": unittest.main() diff --git a/zulip/tests/test_hash_util_decode.py b/zulip/tests/test_hash_util_decode.py index fd50399eab..9129226fcb 100644 --- a/zulip/tests/test_hash_util_decode.py +++ b/zulip/tests/test_hash_util_decode.py @@ -1,23 +1,25 @@ #!/usr/bin/env python3 import unittest +from unittest import TestCase + import zulip -from unittest import TestCase class TestHashUtilDecode(TestCase): def test_hash_util_decode(self) -> None: tests = [ - ('topic', 'topic'), - ('.2Edot', '.dot'), - ('.23stream.20name', '#stream name'), - ('(no.20topic)', '(no topic)'), - ('.3Cstrong.3Ebold.3C.2Fstrong.3E', 'bold'), - ('.3Asome_emoji.3A', ':some_emoji:'), + ("topic", "topic"), + (".2Edot", ".dot"), + (".23stream.20name", "#stream name"), + ("(no.20topic)", "(no topic)"), + (".3Cstrong.3Ebold.3C.2Fstrong.3E", "bold"), + (".3Asome_emoji.3A", ":some_emoji:"), ] for encoded_string, decoded_string in tests: with self.subTest(encoded_string=encoded_string): self.assertEqual(zulip.hash_util_decode(encoded_string), decoded_string) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/zulip/zulip/__init__.py b/zulip/zulip/__init__.py index 629d07efd0..725896e34a 100644 --- a/zulip/zulip/__init__.py +++ b/zulip/zulip/__init__.py @@ -1,21 +1,33 @@ +import argparse import json -import requests -import time -import traceback -import sys -import os +import logging import optparse -import argparse +import os import platform import random +import sys +import time +import traceback import types +import urllib.parse +from configparser import SafeConfigParser from distutils.version import LooseVersion +from typing import ( + IO, + Any, + Callable, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Tuple, + Union, +) import distro -from configparser import SafeConfigParser -import urllib.parse -import logging -from typing import Any, Callable, Dict, Iterable, IO, List, Mapping, Optional, Text, Tuple, Union, Sequence +import requests __version__ = "0.8.0" @@ -26,12 +38,13 @@ # 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, @@ -70,8 +83,7 @@ def succeed(self) -> None: 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: @@ -82,6 +94,7 @@ def _check_success_timeout(self) -> None: ): self.number_of_retries = 0 + class RandomExponentialBackoff(CountingBackoff): def fail(self) -> None: super().fail() @@ -89,16 +102,18 @@ def fail(self) -> None: # between x and 2x where x is growing exponentially delay_scale = int(2 ** (self.number_of_retries / 2.0 - 1)) + 1 delay = min(delay_scale + random.randint(1, delay_scale), self.delay_cap) - message = "Sleeping for %ss [max %s] before retrying." % (delay, delay_scale * 2) + message = f"Sleeping for {delay}s [max {delay_scale * 2}] before retrying." try: logger.warning(message) except NameError: print(message) time.sleep(delay) + def _default_client() -> str: return "ZulipPython/" + __version__ + def add_default_arguments( parser: argparse.ArgumentParser, patch_error_handling: bool = True, @@ -106,167 +121,198 @@ 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)) + self.exit(2, f"{self.prog}: error: {message}\n") + 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)") - - 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 + 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 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 +def generate_option_group(parser: optparse.OptionParser, prefix: str = "") -> optparse.OptionGroup: + 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.""") - - 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 + using zulip.add_default_arguments instead.""" + ) + + group = optparse.OptionGroup(parser, "Zulip API configuration") + group.add_option(f"--{prefix}site", dest="zulip_site", help="Zulip server URI", default=None) + group.add_option(f"--{prefix}api-key", dest="zulip_api_key", action="store") + group.add_option( + f"--{prefix}user", + dest="zulip_email", + help="Email address of the calling bot or user.", + ) + group.add_option( + f"--{prefix}config-file", + 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( + f"--{prefix}client", + 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): - requirements_path = os.path.abspath(os.path.join(sys.path[0], 'requirements.txt')) +def init_from_options(options: Any, client: Optional[str] = None) -> "Client": + + if getattr(options, "provision", False): + requirements_path = os.path.abspath(os.path.join(sys.path[0], "requirements.txt")) try: 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])) + 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], + ) + ) 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]: + +def validate_boolean_field(field: Optional[str]) -> Union[bool, None]: if not isinstance(field, str): return None @@ -279,24 +325,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() @@ -321,16 +381,17 @@ def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None, c if insecure is None: # Be quite strict about what is accepted so that users don't # disable security unintentionally. - insecure_setting = os.environ.get('ZULIP_ALLOW_INSECURE') + insecure_setting = os.environ.get("ZULIP_ALLOW_INSECURE") if insecure_setting is not None: 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() @@ -353,20 +414,24 @@ def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None, c if insecure is None and config.has_option("api", "insecure"): # Be quite strict about what is accepted so that users don't # disable security unintentionally. - insecure_setting = config.get('api', 'insecure') + insecure_setting = config.get("api", "insecure") 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( + f"api_key or email not specified and file {config_file} does not exist" + ) - 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 @@ -388,14 +453,15 @@ def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None, c 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(f"tls bundle '{cert_bundle}' does not exist") self.tls_verification = cert_bundle else: # Default behavior: verify against system CA certificates @@ -403,16 +469,16 @@ def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None, c 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(f"client cert '{client_cert}' does not exist") 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(f"client cert key '{client_cert_key}' does not exist") self.client_cert = client_cert self.client_cert_key = client_cert_key @@ -429,8 +495,11 @@ def ensure_session(self) -> None: # 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 @@ -443,8 +512,8 @@ def ensure_session(self) -> None: self.session = session def get_user_agent(self) -> str: - vendor = '' - vendor_version = '' + vendor = "" + vendor_version = "" try: vendor = platform.system() vendor_version = platform.release() @@ -466,8 +535,15 @@ def get_user_agent(self) -> str: 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 = [] @@ -475,16 +551,16 @@ def do_api_query(self, orig_request: Mapping[str, Any], url: str, method: str = # 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 = [] for (key, val) in orig_request.items(): - if isinstance(val, str) or isinstance(val, Text): + if isinstance(val, str) or isinstance(val, str): request[key] = val else: request[key] = json.dumps(val) @@ -493,12 +569,12 @@ def do_api_query(self, orig_request: Mapping[str, Any], url: str, method: str = 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, - 'request': request, - 'failures': 0, + "had_error_retry": False, + "request": request, + "failures": 0, } # type: Dict[str, Any] def error_retry(error_string: str) -> bool: @@ -506,8 +582,13 @@ def error_retry(error_string: str) -> bool: 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(".") @@ -534,20 +615,21 @@ def end_error_retry(succeeded: bool) -> None: kwargs = {kwarg: query_state["request"]} if files: - kwargs['files'] = req_files + kwargs["files"] = req_files # Actually make the request! res = self.session.request( method, urllib.parse.urljoin(self.base_url, url), timeout=request_timeout, - **kwargs) + **kwargs, + ) self.has_connected = True # On 50x errors, try again after a short sleep - if str(res.status_code).startswith('5'): - if error_retry(" (server %s)" % (res.status_code,)): + if str(res.status_code).startswith("5"): + if error_retry(f" (server {res.status_code})"): continue # Otherwise fall through and process the python-requests error normally except (requests.exceptions.Timeout, requests.exceptions.SSLError) as e: @@ -558,32 +640,38 @@ def end_error_retry(succeeded: bool) -> None: isinstance(e, requests.exceptions.SSLError) and str(e) != "The read operation timed out" ): - raise UnrecoverableNetworkError('SSL Error') + raise UnrecoverableNetworkError("SSL Error") if longpolling: # When longpolling, we expect the timeout to fire, # and the correct response is to just retry continue else: end_error_retry(False) - return {'msg': "Connection error:\n%s" % (traceback.format_exc(),), - "result": "connection-error"} + return { + "msg": f"Connection error:\n{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 # go into retry logic, because the most likely scenario here is # that somebody just hasn't started their server, or they passed # in an invalid site. - raise UnrecoverableNetworkError('cannot connect to server ' + self.base_url) + raise UnrecoverableNetworkError("cannot connect to server " + self.base_url) if error_retry(""): continue end_error_retry(False) - return {'msg': "Connection error:\n%s" % (traceback.format_exc(),), - "result": "connection-error"} + return { + "msg": f"Connection error:\n{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": f"Unexpected error:\n{traceback.format_exc()}", + "result": "unexpected-error", + } try: if requests_json_is_function: @@ -597,11 +685,21 @@ def end_error_retry(succeeded: bool) -> None: 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 = {} @@ -609,8 +707,14 @@ def call_endpoint(self, url: Optional[str] = None, method: str = "POST", request 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, @@ -629,12 +733,12 @@ def do_register() -> Tuple[str, int]: res = self.register(None, None, **kwargs) else: res = self.register(event_types, narrow, **kwargs) - if 'error' in res['result']: + if "error" in res["result"]: if self.verbose: - print("Server returned error:\n%s" % (res['msg'],)) + print("Server returned error:\n{}".format(res["msg"])) time.sleep(1) else: - return (res['queue_id'], res['last_event_id']) + return (res["queue_id"], res["last_event_id"]) queue_id = None # Make long-polling requests with `get_events`. Once a request @@ -645,21 +749,25 @@ def do_register() -> Tuple[str, int]: (queue_id, last_event_id) = do_register() res = self.get_events(queue_id=queue_id, last_event_id=last_event_id) - if 'error' in res['result']: + if "error" in res["result"]: if res["result"] == "http-error": if self.verbose: 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"],)) + print("Server returned error:\n{}".format(res["msg"])) # Eventually, we'll only want the # 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 @@ -676,362 +784,331 @@ def do_register() -> Tuple[str, int]: time.sleep(1) continue - for event in res['events']: - last_event_id = max(last_event_id, int(event['id'])) + for event in res["events"]: + 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) + 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 - ''' - return self.call_endpoint( - url='messages', - method='GET', - request=message_filters - ) + """ + See examples/get-messages for example usage + """ + 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 - ''' - return self.call_endpoint( - url='messages/{}'.format(message_id), - method='GET' - ) + """ + See examples/get-raw-message for example usage + """ + return self.call_endpoint(url=f"messages/{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', + url="messages", request=message_data, ) def upload_file(self, file: IO[Any]) -> Dict[str, Any]: - ''' - See examples/upload-file for example usage. - ''' - return self.call_endpoint( - url='user_uploads', - files=[file] - ) + """ + See examples/upload-file for example usage. + """ + 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': [{...}, {...}]} - ''' - return self.call_endpoint( - url='attachments', - method='GET' - ) + >>> client.get_attachments() + {'result': 'success', 'msg': '', 'attachments': [{...}, {...}]} + """ + 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'],), - method='PATCH', + url="messages/%d" % (message_data["message_id"],), + method="PATCH", request=message_data, ) def delete_message(self, message_id: int) -> Dict[str, Any]: - ''' - See examples/delete-message for example usage. - ''' - return self.call_endpoint( - url='messages/{}'.format(message_id), - method='DELETE' - ) + """ + See examples/delete-message for example usage. + """ + return self.call_endpoint(url=f"messages/{message_id}", method="DELETE") def update_message_flags(self, update_data: Dict[str, Any]) -> Dict[str, Any]: - ''' - See examples/update-flags for example usage. - ''' - return self.call_endpoint( - url='messages/flags', - method='POST', - request=update_data - ) + """ + See examples/update-flags for example usage. + """ + 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', - method='POST', + url="mark_all_as_read", + method="POST", ) 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', - method='POST', - request={'stream_id': stream_id}, + url="mark_stream_as_read", + method="POST", + request={"stream_id": stream_id}, ) 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', - method='POST', + url="mark_topic_as_read", + method="POST", request={ - 'stream_id': stream_id, - 'topic_name': topic_name, + "stream_id": stream_id, + "topic_name": topic_name, }, ) def get_message_history(self, message_id: int) -> Dict[str, Any]: - ''' - See examples/message-history for example usage. - ''' - return self.call_endpoint( - url='messages/{}/history'.format(message_id), - method='GET' - ) + """ + See examples/message-history for example usage. + """ + return self.call_endpoint(url=f"messages/{message_id}/history", method="GET") def add_reaction(self, reaction_data: Dict[str, Any]) -> Dict[str, Any]: - ''' - Example usage: - - >>> client.add_reaction({ - 'message_id': 100, - 'emoji_name': 'joy', - 'emoji_code': '1f602', - 'reaction_type': 'unicode_emoji' - }) - {'result': 'success', 'msg': ''} - ''' + """ + Example usage: + + >>> 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']), - method='POST', + url="messages/{}/reactions".format(reaction_data["message_id"]), + method="POST", request=reaction_data, ) def remove_reaction(self, reaction_data: Dict[str, Any]) -> Dict[str, Any]: - ''' - Example usage: - - >>> client.remove_reaction({ - 'message_id': 100, - 'emoji_name': 'joy', - 'emoji_code': '1f602', - 'reaction_type': 'unicode_emoji' - }) - {'msg': '', 'result': 'success'} - ''' + """ + Example usage: + + >>> 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']), - method='DELETE', + url="messages/{}/reactions".format(reaction_data["message_id"]), + method="DELETE", request=reaction_data, ) def get_realm_emoji(self) -> Dict[str, Any]: - ''' - See examples/realm-emoji for example usage. - ''' - return self.call_endpoint( - url='realm/emoji', - method='GET' - ) + """ + See examples/realm-emoji for example usage. + """ + 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': ''} - ''' - return self.call_endpoint( - 'realm/emoji/{}'.format(emoji_name), - method='POST', - files=[file_obj] - ) + >>> client.upload_custom_emoji(emoji_name, file_obj) + {'result': 'success', 'msg': ''} + """ + return self.call_endpoint(f"realm/emoji/{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), - method='DELETE', + url=f"realm/emoji/{emoji_name}", + method="DELETE", ) def get_realm_linkifiers(self) -> Dict[str, Any]: - ''' - 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', - }, - ] - } - ''' + """ + 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', + }, + ] + } + """ return self.call_endpoint( - url='realm/linkifiers', - method='GET', + url="realm/linkifiers", + method="GET", ) 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', - method='POST', + url="realm/filters", + method="POST", request={ - 'pattern': pattern, - 'url_format_string': url_format_string, + "pattern": pattern, + "url_format_string": url_format_string, }, ) 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), - method='DELETE', + url=f"realm/filters/{filter_id}", + method="DELETE", ) 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', - method='GET', + url="realm/profile_fields", + method="GET", ) 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', - method='POST', + url="realm/profile_fields", + method="POST", request=request, ) 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), - method='DELETE', + url=f"realm/profile_fields/{field_id}", + method="DELETE", ) 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', - method='PATCH', + url="realm/profile_fields", + method="PATCH", request=request, ) 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), - method='PATCH', + url=f"realm/profile_fields/{field_id}", + method="PATCH", request=request, ) 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', - method='GET', + url="server_settings", + method="GET", ) 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', - method='GET', + url="events", + method="GET", longpolling=True, request=request, ) @@ -1040,40 +1117,36 @@ def register( 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', + url="register", request=request, ) def deregister(self, queue_id: str, timeout: Optional[float] = None) -> Dict[str, Any]: - ''' - 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'} - ''' + """ + 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'} + """ request = dict(queue_id=queue_id) return self.call_endpoint( @@ -1084,168 +1157,164 @@ def deregister(self, queue_id: str, timeout: Optional[float] = None) -> Dict[str ) 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', - method='GET', + url="users/me", + method="GET", request=request, ) 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,), - method='GET', + url=f"users/{email}/presence", + method="GET", ) 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', - method='GET', + url="realm/presence", + method="GET", ) def update_presence(self, request: Dict[str, Any]) -> Dict[str, Any]: - ''' - 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': ''} - ''' + """ + 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': ''} + """ return self.call_endpoint( - url='users/me/presence', - method='POST', + url="users/me/presence", + method="POST", request=request, ) 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', - method='GET', + url="streams", + method="GET", request=request, ) 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( - url='streams/{}'.format(stream_data['stream_id']), - method='PATCH', + url="streams/{}".format(stream_data["stream_id"]), + method="PATCH", request=stream_data, ) 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), - method='DELETE', + url=f"streams/{stream_id}", + method="DELETE", ) 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', - method='POST', - request={'stream_id': stream_id}, + url="default_streams", + method="POST", + request={"stream_id": stream_id}, ) 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), - method='GET', + url=f"users/{user_id}", + method="GET", request=request, ) 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), - method='DELETE', + url=f"users/{user_id}", + method="DELETE", ) 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), - method='POST', + url=f"users/{user_id}/reactivate", + method="POST", ) 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=f"users/{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', - method='GET', + url="users", + method="GET", request=request, ) @@ -1257,318 +1326,298 @@ def get_members(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any return self.get_users(request=request) def get_alert_words(self) -> Dict[str, Any]: - ''' - See examples/alert-words for example usage. - ''' - return self.call_endpoint( - url='users/me/alert_words', - method='GET' - ) + """ + See examples/alert-words for example usage. + """ + 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', - method='GET', + url="users/me/subscriptions", + method="GET", request=request, ) 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. - ''' - request = dict( - subscriptions=streams, - **kwargs - ) + """ + See examples/subscribe for example usage. + """ + request = dict(subscriptions=streams, **kwargs) return self.call_endpoint( - url='users/me/subscriptions', + url="users/me/subscriptions", request=request, ) - def remove_subscriptions(self, streams: Iterable[str], - principals: Union[Sequence[str], Sequence[int]] = []) -> Dict[str, Any]: - ''' - See examples/unsubscribe for example usage. - ''' - request = dict( - subscriptions=streams, - principals=principals - ) + def remove_subscriptions( + self, streams: Iterable[str], principals: Union[Sequence[str], Sequence[int]] = [] + ) -> Dict[str, Any]: + """ + See examples/unsubscribe for example usage. + """ + request = dict(subscriptions=streams, principals=principals) return self.call_endpoint( - url='users/me/subscriptions', - method='DELETE', + url="users/me/subscriptions", + method="DELETE", request=request, ) 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), - method='GET', + url=f"users/{user_id}/subscriptions/{stream_id}", + method="GET", ) 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]: - ''' - 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': [{...}, {...}]} - ''' + def update_subscription_settings( + self, subscription_data: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + 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': [{...}, {...}]} + """ return self.call_endpoint( - url='users/me/subscriptions/properties', - method='POST', - request={'subscription_data': subscription_data} + url="users/me/subscriptions/properties", + method="POST", + 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', - method='PATCH', + url="settings/notifications", + method="PATCH", request=notification_settings, ) def get_stream_id(self, stream: str) -> Dict[str, Any]: - ''' - Example usage: client.get_stream_id('devel') - ''' - stream_encoded = urllib.parse.quote(stream, safe='') - url = 'get_stream_id?stream=%s' % (stream_encoded,) + """ + Example usage: client.get_stream_id('devel') + """ + stream_encoded = urllib.parse.quote(stream, safe="") + url = f"get_stream_id?stream={stream_encoded}" return self.call_endpoint( url=url, - method='GET', + method="GET", request=None, ) def get_stream_topics(self, stream_id: int) -> Dict[str, Any]: - ''' - See examples/get-stream-topics for example usage. - ''' - return self.call_endpoint( - url='users/me/{}/topics'.format(stream_id), - method='GET' - ) + """ + See examples/get-stream-topics for example usage. + """ + return self.call_endpoint(url=f"users/me/{stream_id}/topics", 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', - method='GET', + url="user_groups", + method="GET", ) 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', - method='POST', + url="user_groups/create", + method="POST", request=group_data, ) def update_user_group(self, group_data: Dict[str, Any]) -> Dict[str, Any]: - ''' - 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': ''} - ''' + """ + 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': ''} + """ return self.call_endpoint( - url='user_groups/{}'.format(group_data['group_id']), - method='PATCH', + url="user_groups/{}".format(group_data["group_id"]), + method="PATCH", request=group_data, ) 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', + url=f"user_groups/{group_id}", + method="DELETE", ) - def update_user_group_members(self, user_group_id: int, group_data: Dict[str, Any]) -> Dict[str, Any]: - ''' - Example usage: + def update_user_group_members( + self, user_group_id: int, group_data: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 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), - method='POST', + url=f"user_groups/{user_group_id}/members", + method="POST", request=group_data, ) def get_subscribers(self, **request: Any) -> Dict[str, Any]: - ''' - Example usage: client.get_subscribers(stream='devel') - ''' - response = self.get_stream_id(request['stream']) - if response['result'] == 'error': + """ + Example usage: client.get_subscribers(stream='devel') + """ + response = self.get_stream_id(request["stream"]) + if response["result"] == "error": return response - stream_id = response['stream_id'] - url = 'streams/%d/members' % (stream_id,) + stream_id = response["stream_id"] + url = "streams/%d/members" % (stream_id,) return self.call_endpoint( url=url, - method='GET', + method="GET", request=request, ) 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', - method='POST', + url="messages/render", + method="POST", request=request, ) 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', - url='users', + method="POST", + url="users", request=request, ) 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', - method='PUT', + url="bot_storage", + method="PUT", request=request, ) def get_storage(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - ''' - 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': ''} - ''' + """ + 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': ''} + """ return self.call_endpoint( - url='bot_storage', - method='GET', + url="bot_storage", + method="GET", request=request, ) 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': ''} - ''' - return self.call_endpoint( - url='typing', - method='POST', - request=request - ) + """ + Example usage: + >>> client.set_typing_status({ + 'op': 'start', + 'to': [9, 10], + }) + {'result': 'success', 'msg': ''} + """ + return self.call_endpoint(url="typing", method="POST", request=request) def move_topic( self, @@ -1577,72 +1626,74 @@ def move_topic( topic: str, new_topic: Optional[str] = None, message_id: Optional[int] = None, - propagate_mode: str = 'change_all', + 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) - if result['result'] != 'success': + if result["result"] != "success": return result - stream = result['stream_id'] + stream = result["stream_id"] result = self.get_stream_id(new_stream) - if result['result'] != 'success': + if result["result"] != "success": return result - new_stream = result['stream_id'] + new_stream = result["stream_id"] 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"') + if propagate_mode != "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': + if result["result"] != "success": return result - if len(result['messages']) <= 0: - return { - 'result': 'error', - 'msg': 'No messages found in topic: "{}"'.format(topic) - } + if len(result["messages"]) <= 0: + return {"result": "error", "msg": f'No messages found in topic: "{topic}"'} - message_id = result['messages'][0]['id'] + message_id = result["messages"][0]["id"] # move topic containing message to new stream request = { - 'stream_id': new_stream, - 'propagate_mode': propagate_mode, - 'topic': new_topic, - 'send_notification_to_old_thread': notify_old_topic, - 'send_notification_to_new_thread': notify_new_topic + "stream_id": new_stream, + "propagate_mode": propagate_mode, + "topic": new_topic, + "send_notification_to_old_thread": notify_old_topic, + "send_notification_to_new_thread": notify_new_topic, } return self.call_endpoint( - url='messages/{}'.format(message_id), - method='PATCH', + url=f"messages/{message_id}", + method="PATCH", request=request, ) @@ -1659,15 +1710,13 @@ def __init__(self, type: str, to: str, subject: str, **kwargs: Any) -> None: 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. @@ -1678,4 +1727,4 @@ def hash_util_decode(string: str) -> str: """ # Acknowledge custom string replacements in zulip/zulip's zerver/lib/url_encoding.py before unquoting. # NOTE: urllib.parse.unquote already does .replace('%2E', '.'). - return urllib.parse.unquote(string.replace('.', '%')) + return urllib.parse.unquote(string.replace(".", "%")) diff --git a/zulip/zulip/api_examples.py b/zulip/zulip/api_examples.py index 0d67c7cc82..791edd51b0 100644 --- a/zulip/zulip/api_examples.py +++ b/zulip/zulip/api_examples.py @@ -1,27 +1,30 @@ #!/usr/bin/env python3 +import argparse import os + import zulip -import argparse def main() -> None: usage = """zulip-api-examples [script_name] 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)) + 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__': +if __name__ == "__main__": main() diff --git a/zulip/zulip/cli.py b/zulip/zulip/cli.py index da6c7c3c71..a1ec761aa4 100755 --- a/zulip/zulip/cli.py +++ b/zulip/zulip/cli.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 import logging import sys - from typing import Any, Dict, List -import zulip import click +import zulip + logging.basicConfig(stream=sys.stdout, level=logging.INFO) log = logging.getLogger("zulip-cli") diff --git a/zulip/zulip/examples/alert-words b/zulip/zulip/examples/alert-words index 03278e8c8f..4255eed092 100755 --- a/zulip/zulip/examples/alert-words +++ b/zulip/zulip/examples/alert-words @@ -14,17 +14,17 @@ Example: alert-words remove banana parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('operation', choices=['get', 'add', 'remove'], type=str) -parser.add_argument('words', type=str, nargs='*') +parser.add_argument("operation", choices=["get", "add", "remove"], type=str) +parser.add_argument("words", type=str, nargs="*") options = parser.parse_args() client = zulip.init_from_options(options) -if options.operation == 'get': +if options.operation == "get": result = client.get_alert_words() -elif options.operation == 'add': +elif options.operation == "add": result = client.add_alert_words(options.words) -elif options.operation == 'remove': +elif options.operation == "remove": result = client.remove_alert_words(options.words) print(result) diff --git a/zulip/zulip/examples/create-user b/zulip/zulip/examples/create-user index 1ac6b9c8d8..d58f28bb53 100755 --- a/zulip/zulip/examples/create-user +++ b/zulip/zulip/examples/create-user @@ -15,17 +15,21 @@ Specify your Zulip API credentials and server in a ~/.zuliprc file or using the import zulip parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--new-email', required=True) -parser.add_argument('--new-password', required=True) -parser.add_argument('--new-full-name', required=True) -parser.add_argument('--new-short-name', required=True) +parser.add_argument("--new-email", required=True) +parser.add_argument("--new-password", required=True) +parser.add_argument("--new-full-name", required=True) +parser.add_argument("--new-short-name", required=True) 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/delete-message b/zulip/zulip/examples/delete-message index b657f06976..d94a6312eb 100755 --- a/zulip/zulip/examples/delete-message +++ b/zulip/zulip/examples/delete-message @@ -13,7 +13,7 @@ Example: delete-message 42 parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('message_id', type=int) +parser.add_argument("message_id", type=int) options = parser.parse_args() client = zulip.init_from_options(options) diff --git a/zulip/zulip/examples/delete-stream b/zulip/zulip/examples/delete-stream index ca0eb7d35e..eb16001c22 100755 --- a/zulip/zulip/examples/delete-stream +++ b/zulip/zulip/examples/delete-stream @@ -11,7 +11,7 @@ Example: delete-stream 42 parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('stream_id', type=int) +parser.add_argument("stream_id", type=int) options = parser.parse_args() client = zulip.init_from_options(options) diff --git a/zulip/zulip/examples/edit-message b/zulip/zulip/examples/edit-message index b1a8c53b5a..46b95b6a52 100755 --- a/zulip/zulip/examples/edit-message +++ b/zulip/zulip/examples/edit-message @@ -15,9 +15,9 @@ Specify your Zulip API credentials and server in a ~/.zuliprc file or using the import zulip parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--message-id', type=int, required=True) -parser.add_argument('--subject', default="") -parser.add_argument('--content', default="") +parser.add_argument("--message-id", type=int, required=True) +parser.add_argument("--subject", default="") +parser.add_argument("--content", default="") options = parser.parse_args() client = zulip.init_from_options(options) diff --git a/zulip/zulip/examples/edit-stream b/zulip/zulip/examples/edit-stream index 7e88aad4c2..bbee0bbad6 100755 --- a/zulip/zulip/examples/edit-stream +++ b/zulip/zulip/examples/edit-stream @@ -15,25 +15,29 @@ Example: edit-stream --stream-id=3 --history-public-to-subscribers def quote(string: str) -> str: - return '"{}"'.format(string) + return f'"{string}"' parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--stream-id', type=int, required=True) -parser.add_argument('--description') -parser.add_argument('--new-name') -parser.add_argument('--private', action='store_true') -parser.add_argument('--announcement-only', action='store_true') -parser.add_argument('--history-public-to-subscribers', action='store_true') +parser.add_argument("--stream-id", type=int, required=True) +parser.add_argument("--description") +parser.add_argument("--new-name") +parser.add_argument("--private", action="store_true") +parser.add_argument("--announcement-only", action="store_true") +parser.add_argument("--history-public-to-subscribers", action="store_true") 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 6c79ccbea9..c77c81b6d9 100644 --- a/zulip/zulip/examples/get-history +++ b/zulip/zulip/examples/get-history @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import argparse import json -from typing import Dict, List, Any +from typing import Any, Dict, List import zulip @@ -13,27 +13,31 @@ and store them in JSON format. 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("--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'", +) options = parser.parse_args() client = zulip.init_from_options(options) -narrow = [{'operator': 'stream', 'operand': options.stream}] +narrow = [{"operator": "stream", "operand": options.stream}] if options.topic: - narrow.append({'operator': 'topic', 'operand': options.topic}) + narrow.append({"operator": "topic", "operand": options.topic}) request = { # Initially we have the anchor as 0, so that it starts fetching # from the oldest message in the narrow - 'anchor': 0, - 'num_before': 0, - 'num_after': 1000, - 'narrow': narrow, - 'client_gravatar': False, - 'apply_markdown': False + "anchor": 0, + "num_before": 0, + "num_after": 1000, + "narrow": narrow, + "client_gravatar": False, + "apply_markdown": False, } all_messages = [] # type: List[Dict[str, Any]] @@ -43,17 +47,17 @@ while not found_newest: result = client.get_messages(request) try: found_newest = result["found_newest"] - if result['messages']: + if result["messages"]: # Setting the anchor to the next immediate message after the last fetched message. - request['anchor'] = result['messages'][-1]['id'] + 1 + request["anchor"] = result["messages"][-1]["id"] + 1 all_messages.extend(result["messages"]) except KeyError: # Might occur when the request is not returned with a success status - print('Error occured: Payload was:') + print("Error occured: Payload was:") print(result) quit() with open(options.filename, "w+") as f: - print('Writing %d messages...' % len(all_messages)) + print("Writing %d messages..." % len(all_messages)) f.write(json.dumps(all_messages)) diff --git a/zulip/zulip/examples/get-messages b/zulip/zulip/examples/get-messages index 0bd944e33d..db245d48e1 100755 --- a/zulip/zulip/examples/get-messages +++ b/zulip/zulip/examples/get-messages @@ -17,23 +17,27 @@ Example: get-messages --use-first-unread-anchor --num-before=5 \\ parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--anchor', type=int) -parser.add_argument('--use-first-unread-anchor', action='store_true') -parser.add_argument('--num-before', type=int, required=True) -parser.add_argument('--num-after', type=int, required=True) -parser.add_argument('--client-gravatar', action='store_true') -parser.add_argument('--apply-markdown', action='store_true') -parser.add_argument('--narrow') +parser.add_argument("--anchor", type=int) +parser.add_argument("--use-first-unread-anchor", action="store_true") +parser.add_argument("--num-before", type=int, required=True) +parser.add_argument("--num-after", type=int, required=True) +parser.add_argument("--client-gravatar", action="store_true") +parser.add_argument("--apply-markdown", action="store_true") +parser.add_argument("--narrow") 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/get-raw-message b/zulip/zulip/examples/get-raw-message index 7302679c63..486970ff8b 100755 --- a/zulip/zulip/examples/get-raw-message +++ b/zulip/zulip/examples/get-raw-message @@ -12,7 +12,7 @@ Example: get-raw-message 42 parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('message_id', type=int) +parser.add_argument("message_id", type=int) options = parser.parse_args() client = zulip.init_from_options(options) diff --git a/zulip/zulip/examples/get-stream-topics b/zulip/zulip/examples/get-stream-topics index 2d29b5ddd3..2cc5726668 100755 --- a/zulip/zulip/examples/get-stream-topics +++ b/zulip/zulip/examples/get-stream-topics @@ -10,7 +10,7 @@ Get all the topics for a specific stream. import zulip parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--stream-id', required=True) +parser.add_argument("--stream-id", required=True) options = parser.parse_args() client = zulip.init_from_options(options) diff --git a/zulip/zulip/examples/get-user-presence b/zulip/zulip/examples/get-user-presence index 4e70350df2..a6dec2a85f 100755 --- a/zulip/zulip/examples/get-user-presence +++ b/zulip/zulip/examples/get-user-presence @@ -10,7 +10,7 @@ Get presence data for another user. import zulip parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--email', required=True) +parser.add_argument("--email", required=True) options = parser.parse_args() client = zulip.init_from_options(options) diff --git a/zulip/zulip/examples/message-history b/zulip/zulip/examples/message-history index ba2aea74f5..7689591c2c 100755 --- a/zulip/zulip/examples/message-history +++ b/zulip/zulip/examples/message-history @@ -10,7 +10,7 @@ Example: message-history 42 """ parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('message_id', type=int) +parser.add_argument("message_id", type=int) options = parser.parse_args() client = zulip.init_from_options(options) diff --git a/zulip/zulip/examples/mute-topic b/zulip/zulip/examples/mute-topic index a105147128..36ff07f528 100755 --- a/zulip/zulip/examples/mute-topic +++ b/zulip/zulip/examples/mute-topic @@ -11,20 +11,17 @@ Example: mute-topic unmute Denmark party """ parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('op', choices=['mute', 'unmute']) -parser.add_argument('stream') -parser.add_argument('topic') +parser.add_argument("op", choices=["mute", "unmute"]) +parser.add_argument("stream") +parser.add_argument("topic") 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 859e0d3c3f..28b81eefc6 100755 --- a/zulip/zulip/examples/print-events +++ b/zulip/zulip/examples/print-events @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import argparse - from typing import Any, Dict usage = """print-events [options] @@ -20,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 7f0b0f84d6..e97d1ee6fd 100755 --- a/zulip/zulip/examples/print-messages +++ b/zulip/zulip/examples/print-messages @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import argparse - from typing import Any, Dict usage = """print-messages [options] @@ -20,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/send-message b/zulip/zulip/examples/send-message index 4a29830613..5d9fca390d 100755 --- a/zulip/zulip/examples/send-message +++ b/zulip/zulip/examples/send-message @@ -12,18 +12,18 @@ Example: send-message --type=stream commits --subject="my subject" --message="te Example: send-message user1@example.com user2@example.com """ parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('recipients', nargs='+') -parser.add_argument('--subject', default='test') -parser.add_argument('--message', default='test message') -parser.add_argument('--type', default='private') +parser.add_argument("recipients", nargs="+") +parser.add_argument("--subject", default="test") +parser.add_argument("--message", default="test message") +parser.add_argument("--type", default="private") options = parser.parse_args() client = zulip.init_from_options(options) message_data = { - 'type': options.type, - 'content': options.message, - 'subject': options.subject, - 'to': options.recipients, + "type": options.type, + "content": options.message, + "subject": options.subject, + "to": options.recipients, } print(client.send_message(message_data)) diff --git a/zulip/zulip/examples/subscribe b/zulip/zulip/examples/subscribe index fc3153d57f..a1436e4902 100755 --- a/zulip/zulip/examples/subscribe +++ b/zulip/zulip/examples/subscribe @@ -15,10 +15,9 @@ Specify your Zulip API credentials and server in a ~/.zuliprc file or using the import zulip parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--streams', action='store', required=True) +parser.add_argument("--streams", action="store", required=True) 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/unsubscribe b/zulip/zulip/examples/unsubscribe index d55b90eace..d226e2d014 100755 --- a/zulip/zulip/examples/unsubscribe +++ b/zulip/zulip/examples/unsubscribe @@ -15,7 +15,7 @@ Specify your Zulip API credentials and server in a ~/.zuliprc file or using the import zulip parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--streams', action='store', required=True) +parser.add_argument("--streams", action="store", required=True) options = parser.parse_args() client = zulip.init_from_options(options) diff --git a/zulip/zulip/examples/update-message-flags b/zulip/zulip/examples/update-message-flags index d134f627d1..ada15a6491 100755 --- a/zulip/zulip/examples/update-message-flags +++ b/zulip/zulip/examples/update-message-flags @@ -12,15 +12,15 @@ Example: update-message-flags remove starred 16 23 42 """ parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('op', choices=['add', 'remove']) -parser.add_argument('flag') -parser.add_argument('messages', type=int, nargs='+') +parser.add_argument("op", choices=["add", "remove"]) +parser.add_argument("flag") +parser.add_argument("messages", type=int, nargs="+") 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 f432a7557c..bdb1415182 100755 --- a/zulip/zulip/examples/upload-file +++ b/zulip/zulip/examples/upload-file @@ -1,13 +1,15 @@ #!/usr/bin/env python3 import argparse - from io import StringIO as _StringIO from typing import IO, Any + import zulip + class StringIO(_StringIO): - name = '' # https://github.com/python/typeshed/issues/598 + name = "" # https://github.com/python/typeshed/issues/598 + usage = """upload-file [options] @@ -20,20 +22,20 @@ If no --file-path is specified, a placeholder text file will be used instead. """ parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) -parser.add_argument('--file-path', required=True) +parser.add_argument("--file-path", required=True) options = parser.parse_args() client = zulip.init_from_options(options) if options.file_path: - file = open(options.file_path, 'rb') # type: IO[Any] + file = open(options.file_path, "rb") # type: IO[Any] else: - file = StringIO('This is a test file.') - file.name = 'test.txt' + file = StringIO("This is a test file.") + file.name = "test.txt" response = client.upload_file(file) try: - print('File URI: {}'.format(response['uri'])) + print("File URI: {}".format(response["uri"])) except KeyError: - print('Error! API response was: {}'.format(response)) + print(f"Error! API response was: {response}") diff --git a/zulip/zulip/examples/welcome-message b/zulip/zulip/examples/welcome-message index 6140c4dad9..b6e8986f7e 100755 --- a/zulip/zulip/examples/welcome-message +++ b/zulip/zulip/examples/welcome-message @@ -1,9 +1,10 @@ #!/usr/bin/env python3 +from typing import Any, Dict, List + import zulip -from typing import Any, List, Dict -welcome_text = 'Hello {}, Welcome to Zulip!\n \ +welcome_text = "Hello {}, Welcome to Zulip!\n \ * The first thing you should do is to install the development environment. \ We recommend following the vagrant setup as it is well documented and used \ by most of the contributors. If you face any trouble during installation \ @@ -20,7 +21,7 @@ of the main projects you can contribute to are Zulip \ a [bot](https://github.com/zulip/zulipbot) that you can contribute to!!\n \ * We host our source code on GitHub. If you are not familiar with Git or \ GitHub checkout [this](http://zulip.readthedocs.io/en/latest/git-guide.html) \ -guide. You don\'t have to learn everything but please go through it and learn \ +guide. You don't have to learn everything but please go through it and learn \ the basics. We are here to help you if you are having any trouble. Post your \ questions in #git help . \ * Once you have completed these steps you can start contributing. You \ @@ -32,52 +33,57 @@ but if you want a bite size issue for mobile or electron feel free post in #mobi or #electron .\n \ * Solving the first issue can be difficult. The key is to not give up. If you spend \ enough time on the issue you should be able to solve it no matter what.\n \ -* Use `grep` command when you can\'t figure out what files to change :) For example \ +* Use `grep` command when you can't figure out what files to change :) For example \ if you want know what files to modify in order to change Invite more users to Add \ -more users which you can see below the user status list, grep for "Invite more \ -users" in terminal.\n \ -* If you are stuck with something and can\'t figure out what to do you can ask \ +more users which you can see below the user status list, grep for \"Invite more \ +users\" in terminal.\n \ +* If you are stuck with something and can't figure out what to do you can ask \ for help in #development help . But make sure that you tried your best to figure \ out the issue by yourself\n \ -* If you are here for #Outreachy 2017-2018 or #GSoC don\'t worry much about \ +* If you are here for #Outreachy 2017-2018 or #GSoC don't worry much about \ whether you will get selected or not. You will learn a lot contributing to \ Zulip in course of next few months and if you do a good job at that you \ will get selected too :)\n \ -* Most important of all welcome to the Zulip family :octopus:' +* Most important of all welcome to the Zulip family :octopus:" # These streams will cause the message to be sent -streams_to_watch = ['new members'] +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'] +streams_to_cancel = ["development help"] + def get_watchlist() -> List[Any]: storage = client.get_storage() - return list(storage['storage'].values()) + return list(storage["storage"].values()) + def set_watchlist(watchlist: List[str]) -> None: - client.update_storage({'storage': dict(enumerate(watchlist))}) + 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': + if event["type"] == "realm_user" and event["op"] == "add": watchlist = get_watchlist() - watchlist.append(event['person']['email']) + watchlist.append(event["person"]["email"]) set_watchlist(watchlist) return - if event['type'] == 'message': - stream = event['message']['display_recipient'] + if event["type"] == "message": + stream = event["message"]["display_recipient"] if stream not in streams_to_watch and stream not in streams_to_cancel: return watchlist = get_watchlist() - if event['message']['sender_email'] in watchlist: - watchlist.remove(event['message']['sender_email']) + 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: @@ -86,7 +92,8 @@ def handle_event(event: Dict[str, Any]) -> None: def start_event_handler() -> None: print("Starting event handler...") - client.call_on_each_event(handle_event, event_types=['realm_user', 'message']) + 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 6c2feb3e48..fc580483e1 100755 --- a/zulip/zulip/send.py +++ b/zulip/zulip/send.py @@ -1,34 +1,37 @@ #!/usr/bin/env python3 # zulip-send -- Sends a message to the specified recipients. -import sys import argparse import logging - +import sys from typing import Any, Dict import zulip logging.basicConfig() -log = logging.getLogger('zulip-send') +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.''' + """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'])) + if message_data["type"] == "stream": + 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'],)) + log.info("Sending message to {}... ".format(message_data["to"])) response = client.send_message(message_data) - if response['result'] == 'success': - log.info('Message sent.') + if response["result"] == "success": + log.info("Message sent.") return True else: - log.error(response['msg']) + log.error(response["msg"]) return False + def main() -> int: usage = """zulip-send [options] [recipient...] @@ -42,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('-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.') + 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." + ) + + 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.", + ) options = parser.parse_args() @@ -65,11 +75,11 @@ def main() -> int: logging.getLogger().setLevel(logging.INFO) # Sanity check user data if len(options.recipients) != 0 and (options.stream or options.subject): - parser.error('You cannot specify both a username and a stream/subject.') + parser.error("You cannot specify both a username and a stream/subject.") if len(options.recipients) == 0 and (bool(options.stream) != bool(options.subject)): - parser.error('Stream messages must have a subject') + parser.error("Stream messages must have a subject") if len(options.recipients) == 0 and not (options.stream and options.subject): - parser.error('You must specify a stream/subject or at least one recipient.') + parser.error("You must specify a stream/subject or at least one recipient.") client = zulip.init_from_options(options) @@ -78,21 +88,22 @@ def main() -> int: if options.stream: message_data = { - 'type': 'stream', - 'content': options.message, - 'subject': options.subject, - 'to': options.stream, + "type": "stream", + "content": options.message, + "subject": options.subject, + "to": options.stream, } else: message_data = { - 'type': 'private', - 'content': options.message, - 'to': options.recipients, + "type": "private", + "content": options.message, + "to": options.recipients, } if not do_send_message(client, message_data): return 1 return 0 -if __name__ == '__main__': + +if __name__ == "__main__": sys.exit(main()) diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py index 8a3d4eb0a7..9ab6ca1632 100644 --- a/zulip_bots/setup.py +++ b/zulip_bots/setup.py @@ -9,50 +9,50 @@ package_data = { - '': ['doc.md', '*.conf', 'assets/*'], - 'zulip_bots': ['py.typed'], + "": ["doc.md", "*.conf", "assets/*"], + "zulip_bots": ["py.typed"], } # IS_PYPA_PACKAGE is set to True by tools/release-packages # before making a PyPA release. if not IS_PYPA_PACKAGE: - package_data[''].append('fixtures/*.json') - package_data[''].append('logo.*') + package_data[""].append("fixtures/*.json") + package_data[""].append("logo.*") with open("README.md") as fh: long_description = fh.read() # We should be installable with either setuptools or distutils. package_info = dict( - name='zulip_bots', + name="zulip_bots", version=ZULIP_BOTS_VERSION, - description='Zulip\'s Bot framework', + description="Zulip's Bot framework", long_description=long_description, long_description_content_type="text/markdown", - author='Zulip Open Source Project', - author_email='zulip-devel@googlegroups.com', + author="Zulip Open Source Project", + author_email="zulip-devel@googlegroups.com", classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Topic :: Communications :: Chat', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Topic :: Communications :: Chat", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", ], - python_requires='>=3.6', - url='https://www.zulip.org/', + python_requires=">=3.6", + url="https://www.zulip.org/", project_urls={ "Source": "https://github.com/zulip/python-zulip-api/", "Documentation": "https://zulip.com/api", }, entry_points={ - 'console_scripts': [ - 'zulip-run-bot=zulip_bots.run:main', - 'zulip-terminal=zulip_bots.terminal:main' + "console_scripts": [ + "zulip-run-bot=zulip_bots.run:main", + "zulip-terminal=zulip_bots.terminal:main", ], }, include_package_data=True, @@ -60,20 +60,21 @@ setuptools_info = dict( install_requires=[ - 'pip', - 'zulip', - 'html2text', - 'lxml', - 'BeautifulSoup4', - 'typing_extensions', + "pip", + "zulip", + "html2text", + "lxml", + "BeautifulSoup4", + "typing_extensions", ], ) try: - from setuptools import setup, find_packages + from setuptools import find_packages, setup + package_info.update(setuptools_info) - package_info['packages'] = find_packages() - package_info['package_data'] = package_data + package_info["packages"] = find_packages() + package_info["package_data"] = package_data except ImportError: from distutils.core import setup @@ -85,26 +86,28 @@ def check_dependency_manually(module_name: str, version: Optional[str] = None) - 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( + f"{module_name}>={version} is not installed.", + file=sys.stderr, + ) else: - print("{name} is not installed.".format(name=module_name), file=sys.stderr) + print(f"{module_name} is not installed.", file=sys.stderr) sys.exit(1) - check_dependency_manually('zulip') - check_dependency_manually('mock', '2.0.0') - check_dependency_manually('html2text') - check_dependency_manually('PyDictionary') + check_dependency_manually("zulip") + check_dependency_manually("mock", "2.0.0") + check_dependency_manually("html2text") + check_dependency_manually("PyDictionary") # Include all submodules under bots/ - package_list = ['zulip_bots'] - dirs = os.listdir('zulip_bots/bots/') + package_list = ["zulip_bots"] + dirs = os.listdir("zulip_bots/bots/") for dir_name in dirs: - if os.path.isdir(os.path.join('zulip_bots/bots/', dir_name)): - package_list.append('zulip_bots.bots.' + dir_name) - package_info['packages'] = package_list + if os.path.isdir(os.path.join("zulip_bots/bots/", dir_name)): + package_list.append("zulip_bots.bots." + dir_name) + package_info["packages"] = package_list setup(**package_info) diff --git a/zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py b/zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py index e84d29a110..c6d303d5f3 100644 --- a/zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py +++ b/zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py @@ -1,30 +1,40 @@ # See readme.md for instructions on running this code. -from typing import Any, List, Dict -from zulip_bots.lib import BotHandler +from typing import Any, Dict, List + import requests +from zulip_bots.lib import BotHandler + + class BaremetricsHandler: def initialize(self, bot_handler: BotHandler) -> None: - 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.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.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.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.check_api_key(bot_handler) @@ -34,36 +44,36 @@ def check_api_key(self, bot_handler: BotHandler) -> None: test_query_data = test_query_response.json() try: - if test_query_data['error'] == "Unauthorized. Token not found (001)": - bot_handler.quit('API Key not valid. Please see doc.md to find out how to get it.') + if test_query_data["error"] == "Unauthorized. Token not found (001)": + bot_handler.quit("API Key not valid. Please see doc.md to find out how to get it.") except KeyError: pass def usage(self) -> str: - return ''' + return """ 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 handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: - content = message['content'].strip().split() + content = message["content"].strip().split() if content == []: - bot_handler.send_reply(message, 'No Command Specified') + bot_handler.send_reply(message, "No Command Specified") return content[0] = content[0].lower() - if content == ['help']: + if content == ["help"]: bot_handler.send_reply(message, self.usage()) return - if content == ['list-commands']: - response = '**Available Commands:** \n' + if content == ["list-commands"]: + response = "**Available Commands:** \n" for command, description in zip(self.commands, self.descriptions): - response += ' - {} : {}\n'.format(command, description) + response += f" - {command} : {description}\n" bot_handler.send_reply(message, response) return @@ -75,164 +85,177 @@ def generate_response(self, commands: List[str]) -> str: try: instruction = commands[0] - if instruction == 'account-info': + if instruction == "account-info": return self.get_account_info() - if instruction == 'list-sources': + if instruction == "list-sources": return self.get_sources() try: - if instruction == 'list-plans': + if instruction == "list-plans": return self.get_plans(commands[1]) - if instruction == 'list-customers': + if instruction == "list-customers": return self.get_customers(commands[1]) - if instruction == 'list-subscriptions': + if instruction == "list-subscriptions": return self.get_subscriptions(commands[1]) - if instruction == 'create-plan': + if instruction == "create-plan": if len(commands) == 8: return self.create_plan(commands[1:]) else: - return 'Invalid number of arguments.' + return "Invalid number of arguments." except IndexError: - return 'Missing Params.' + return "Missing Params." except KeyError: - return 'Invalid Response From API.' + return "Invalid Response From API." - return 'Invalid Command.' + return "Invalid Command." def get_account_info(self) -> str: url = "https://api.baremetrics.com/v1/account" account_response = requests.get(url, headers=self.auth_header) account_data = account_response.json() - account_data = account_data['account'] + 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' + url = "https://api.baremetrics.com/v1/sources" sources_response = requests.get(url, headers=self.auth_header) sources_data = sources_response.json() - sources_data = sources_data['sources'] + sources_data = sources_data["sources"] - response = '**Listing sources:** \n' + 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 def get_plans(self, source_id: str) -> str: - url = 'https://api.baremetrics.com/v1/{}/plans'.format(source_id) + url = f"https://api.baremetrics.com/v1/{source_id}/plans" plans_response = requests.get(url, headers=self.auth_header) 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:']) - response = ['**Listing plans:**'] + plans_data = plans_data["plans"] + + 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"]] + + [""] ) - return '\n'.join(response) + return "\n".join(response) def get_customers(self, source_id: str) -> str: - url = 'https://api.baremetrics.com/v1/{}/customers'.format(source_id) + url = f"https://api.baremetrics.com/v1/{source_id}/customers" customers_response = requests.get(url, headers=self.auth_header) customers_data = customers_response.json() - customers_data = customers_data['customers'] + 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:']) - response = ['**Listing customers:**'] + 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 += ( [template.format(_count=index + 1, **customer)] - + [' - {name}'.format(**plan) for plan in customer['current_plans']] - + [''] + + [" - {name}".format(**plan) for plan in customer["current_plans"]] + + [""] ) - return '\n'.join(response) + return "\n".join(response) def get_subscriptions(self, source_id: str) -> str: - url = 'https://api.baremetrics.com/v1/{}/subscriptions'.format(source_id) + url = f"https://api.baremetrics.com/v1/{source_id}/subscriptions" subscriptions_response = requests.get(url, headers=self.auth_header) 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:']) - response = ['**Listing subscriptions:**'] + 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:", + ] + ) + response = ["**Listing subscriptions:**"] for index, subscription in enumerate(subscriptions_data): response += ( [ template.format( _count=index + 1, - _active=subscription['active'], - _plan_name=subscription['plan']['name'], - **subscription['customer'] + _active=subscription["active"], + _plan_name=subscription["plan"]["name"], + **subscription["customer"], ) ] + [ - ' - {amount} {symbol}'.format(**amount) - for amount in subscription['plan']['amounts'] + " - {amount} {symbol}".format(**amount) + for amount in subscription["plan"]["amounts"] ] - + [''] + + [""] ) - return '\n'.join(response) + return "\n".join(response) def create_plan(self, parameters: List[str]) -> str: data_header = { - 'oid': parameters[1], - 'name': parameters[2], - 'currency': parameters[3], - 'amount': int(parameters[4]), - 'interval': parameters[5], - 'interval_count': int(parameters[6]) + "oid": parameters[1], + "name": parameters[2], + "currency": parameters[3], + "amount": int(parameters[4]), + "interval": parameters[5], + "interval_count": int(parameters[6]), } # type: Any - url = 'https://api.baremetrics.com/v1/{}/plans'.format(parameters[0]) + url = f"https://api.baremetrics.com/v1/{parameters[0]}/plans" create_plan_response = requests.post(url, data=data_header, headers=self.auth_header) - if 'error' not in create_plan_response.json(): - return 'Plan Created.' + if "error" not in create_plan_response.json(): + return "Plan Created." else: - return 'Invalid Arguments Error.' + 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 15b231d435..6aeb6e2214 100644 --- a/zulip_bots/zulip_bots/bots/baremetrics/test_baremetrics.py +++ b/zulip_bots/zulip_bots/bots/baremetrics/test_baremetrics.py @@ -1,116 +1,128 @@ from unittest.mock import patch -from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.test_lib import StubBotHandler + from zulip_bots.bots.baremetrics.baremetrics import BaremetricsHandler +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler + 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'): - self.verify_reply('', 'No Command Specified') + 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') + 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", + ) 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') + 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", + ) 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' \ - ' - 450000 USD\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) + 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' \ - 'Email: customer_1@baremetrics.com\nNotes: Here are some notes\nCurrent Plans:\n - Plan 1\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) + 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' \ - 'Plan Name: Plan 1\nPlan Amounts:\n - 1000 $\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'): - self.verify_reply('list-subscriptions TEST', r) + with self.mock_config_info({"api_key": "TEST"}): + with self.mock_http_conversation("list_subscriptions"): + self.verify_reply("list-subscriptions TEST", r) def test_exception_when_api_key_is_invalid(self) -> None: bot_test_instance = BaremetricsHandler() - with self.mock_config_info({'api_key': 'TEST'}): - with self.mock_http_conversation('invalid_api_key'): + with self.mock_config_info({"api_key": "TEST"}): + with self.mock_http_conversation("invalid_api_key"): with self.assertRaises(StubBotHandler.BotQuitException): bot_test_instance.initialize(StubBotHandler()) def test_invalid_command(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): - self.verify_reply('abcd', 'Invalid Command.') + 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'): - self.verify_reply('list-plans', 'Missing Params.') + 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_http_conversation('test_key_error'): - self.verify_reply('list-plans TEST', 'Invalid Response From API.') + 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_http_conversation('create_plan'): - self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Plan Created.') + 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_http_conversation('create_plan_error'): - self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Invalid Arguments Error.') + 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." + ) def test_create_plan_argnum_error_command(self) -> None: - with self.mock_config_info({'api_key': 'TEST'}), \ - patch('requests.get'): - self.verify_reply('create-plan alpha beta', 'Invalid number of arguments.') + 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 858af0987a..d15c79658b 100644 --- a/zulip_bots/zulip_bots/bots/beeminder/beeminder.py +++ b/zulip_bots/zulip_bots/bots/beeminder/beeminder.py @@ -1,10 +1,12 @@ -import requests import logging from typing import Dict -from zulip_bots.lib import BotHandler + +import requests from requests.exceptions import ConnectionError -help_message = ''' +from zulip_bots.lib import BotHandler + +help_message = """ You can add datapoints towards your beeminder goals \ following the syntax shown below :smile:.\n \ \n**@mention-botname daystamp, value, comment**\ @@ -12,48 +14,48 @@ [**NOTE:** Optional field, default is *current daystamp*],\ \n* `value`**:** Enter a value [**NOTE:** Required field, can be any number],\ \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'] - auth_token = config_info['auth_token'] + username = config_info["username"] + goalname = config_info["goalname"] + auth_token = config_info["auth_token"] message_content = message_content.strip() - if message_content == '' or message_content == 'help': + if message_content == "" or message_content == "help": return help_message - url = "https://www.beeminder.com/api/v1/users/{}/goals/{}/datapoints.json".format(username, goalname) - message_pieces = message_content.split(',') + 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" @@ -61,13 +63,17 @@ def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> 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 + datapoint_link = f"https://www.beeminder.com/{username}/{goalname}" + 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 \ @@ -75,19 +81,21 @@ def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> class BeeminderHandler: - ''' + """ This plugin allows users to easily add datapoints towards their beeminder goals via zulip - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('beeminder') + self.config_info = bot_handler.get_config_info("beeminder") # Check for valid auth_token - auth_token = self.config_info['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!') + bot_handler.quit("Invalid key!") except ConnectionError as e: logging.exception(str(e)) @@ -95,7 +103,8 @@ def usage(self) -> str: return "This plugin allows users to add datapoints towards their Beeminder goals" def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - response = get_beeminder_response(message['content'], self.config_info) + 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 909a365a48..516ed1d603 100644 --- a/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py +++ b/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py @@ -1,16 +1,15 @@ from unittest.mock import patch -from zulip_bots.test_lib import StubBotHandler, BotTestCase, DefaultTests, get_bot_message_handler + from requests.exceptions import ConnectionError +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_bot_message_handler + + 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 = ''' + help_message = """ You can add datapoints towards your beeminder goals \ following the syntax shown below :smile:.\n \ \n**@mention-botname daystamp, value, comment**\ @@ -18,91 +17,97 @@ class TestBeeminderBot(BotTestCase, DefaultTests): [**NOTE:** Optional field, default is *current daystamp*],\ \n* `value`**:** Enter a value [**NOTE:** Required field, can be any number],\ \n* `comment`**:** Add a comment [**NOTE:** Optional field, default is *None*]\ -''' +""" 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'): - self.verify_reply('', self.help_message) + 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'): - self.verify_reply('help', self.help_message) + 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'): - self.verify_reply('20180602, 2', bot_response) + 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"): + 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'): - self.verify_reply('2, hi there!', bot_response) + 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"): + 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'): - self.verify_reply('20180602, 2, hi there!', bot_response) + 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"): + 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'): - self.verify_reply('5', 'Error. Check your key!') + 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_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 79696a81e2..177c3d1974 100644 --- a/zulip_bots/zulip_bots/bots/chessbot/chessbot.py +++ b/zulip_bots/zulip_bots/bots/chessbot/chessbot.py @@ -1,49 +1,44 @@ +import copy +import re +from typing import Any, Dict, Optional + import chess import chess.uci -import re -import copy -from typing import Any, Optional, Dict + 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' -) -MOVE_REGEX = re.compile('do (?P.+)$') -RESIGN_REGEX = re.compile('resign$') +START_REGEX = re.compile("start with other user$") +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 ( - 'Chess Bot is a bot that allows you to play chess against either ' - 'another user or the computer. Use `start with other user` or ' - '`start as with computer` to start a game.\n\n' - 'In order to play against a computer, `chess.conf` must be set ' - 'with the key `stockfish_location` set to the location of the ' - 'Stockfish program on this computer.' + "Chess Bot is a bot that allows you to play chess against either " + "another user or the computer. Use `start with other user` or " + "`start as with computer` to start a game.\n\n" + "In order to play against a computer, `chess.conf` must be set " + "with the key `stockfish_location` set to the location of the " + "Stockfish program on this computer." ) def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('chess') + 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.') + print("That Stockfish doesn't exist. Continuing.") - def handle_message( - self, - message: Dict[str, str], - bot_handler: BotHandler - ) -> None: - content = message['content'] + def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: + content = message["content"] - if content == '': + if content == "": bot_handler.send_reply(message, self.usage()) return @@ -55,44 +50,31 @@ def handle_message( is_with_computer = False last_fen = chess.Board().fen() - if bot_handler.storage.contains('is_with_computer'): + 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'): - last_fen = bot_handler.storage.get('last_fen') + if bot_handler.storage.contains("last_fen"): + last_fen = bot_handler.storage.get("last_fen") if start_regex_match: 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. @@ -103,21 +85,15 @@ def start(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - 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)) + bot_handler.storage.put("is_with_computer", str(False)) - bot_handler.storage.put('last_fen', new_board.fen()) + 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. @@ -133,15 +109,12 @@ def start_computer( 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)) + bot_handler.storage.put("is_with_computer", str(True)) - bot_handler.storage.put('last_fen', new_board.fen()) + bot_handler.storage.put("last_fen", new_board.fen()) else: self.move_computer_first( message, @@ -150,10 +123,7 @@ def start_computer( ) 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. @@ -169,10 +139,7 @@ def validate_board( 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 @@ -183,7 +150,7 @@ def validate_move( 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. @@ -203,29 +170,17 @@ def validate_move( 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, @@ -249,41 +204,27 @@ def check_game_over( # wants the game to be a draw, after 3 or 75 it a draw. For now, # just assume that the players would want the draw. if new_board.is_game_over(True): - game_over_output = '' + 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') + 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') + 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. @@ -299,13 +240,7 @@ def move( 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 @@ -316,19 +251,12 @@ def move( 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()) + 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`, @@ -348,13 +276,7 @@ def move_computer( 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 @@ -365,40 +287,22 @@ def move_computer( 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 @@ -413,44 +317,24 @@ def move_computer_first( """ 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)) + 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: @@ -463,13 +347,12 @@ def resign( 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. @@ -483,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. @@ -492,7 +376,8 @@ def make_draw_response(reason: str) -> str: Returns: The draw response string. """ - return 'It\'s a draw because of {}!'.format(reason) + return f"It's a draw because of {reason}!" + def make_loss_response(board: chess.Board, reason: str) -> str: """Makes a response string for a loss (or win). @@ -504,16 +389,14 @@ def make_loss_response(board: chess.Board, reason: str) -> str: Returns: The loss response string. """ - return ( - '*{}* {}. **{}** wins!\n\n' - '{}' - ).format( - 'White' if board.turn else 'Black', + return ("*{}* {}. **{}** wins!\n\n" "{}").format( + "White" if board.turn else "Black", reason, - 'Black' if board.turn else 'White', - make_str(board, board.turn) + "Black" if board.turn else "White", + 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. @@ -523,27 +406,22 @@ 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. Returns: The copied-wrong response string. """ return ( - 'Sorry, it seems like you copied down the response wrong.\n\n' - 'Please try to copy the response again from the last message!' + "Sorry, it seems like you copied down the response wrong.\n\n" + "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. @@ -555,17 +433,14 @@ def make_start_reponse(board: chess.Board) -> str: Returns: The starting response string. """ return ( - 'New game! The board looks like this:\n\n' - '{}' - '\n\n\n' - 'Now it\'s **{}**\'s turn.' - '\n\n\n' - '{}' - ).format( - make_str(board, True), - 'white' if board.turn else 'black', - make_footer() - ) + "New game! The board looks like this:\n\n" + "{}" + "\n\n\n" + "Now it's **{}**'s turn." + "\n\n\n" + "{}" + ).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 @@ -579,23 +454,16 @@ def make_start_computer_reponse(board: chess.Board) -> str: Returns: The starting response string. """ return ( - 'New game with computer! The board looks like this:\n\n' - '{}' - '\n\n\n' - 'Now it\'s **{}**\'s turn.' - '\n\n\n' - '{}' - ).format( - make_str(board, True), - 'white' if board.turn else 'black', - make_footer() - ) + "New game with computer! The board looks like this:\n\n" + "{}" + "\n\n\n" + "Now it's **{}**'s turn." + "\n\n\n" + "{}" + ).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: @@ -606,35 +474,37 @@ def make_move_reponse( Returns: The move response string. """ return ( - 'The board was like this:\n\n' - '{}' - '\n\n\n' - 'Then *{}* moved *{}*:\n\n' - '{}' - '\n\n\n' - 'Now it\'s **{}**\'s turn.' - '\n\n\n' - '{}' + "The board was like this:\n\n" + "{}" + "\n\n\n" + "Then *{}* moved *{}*:\n\n" + "{}" + "\n\n\n" + "Now it's **{}**'s turn." + "\n\n\n" + "{}" ).format( make_str(last_board, new_board.turn), - 'white' if last_board.turn else 'black', + "white" if last_board.turn else "black", last_board.san(move), make_str(new_board, new_board.turn), - 'white' if new_board.turn else 'black', - make_footer() + "white" if new_board.turn else "black", + make_footer(), ) + def make_footer() -> str: """Makes a footer to be appended to the bottom of other, actionable responses. """ return ( - 'To make your next move, respond to Chess Bot with\n\n' - '```do ```\n\n' - '*Remember to @-mention Chess Bot at the beginning of your ' - 'response.*' + "To make your next move, respond to Chess Bot with\n\n" + "```do ```\n\n" + "*Remember to @-mention Chess Bot at the beginning of your " + "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. @@ -652,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) + monospaced_str = f"```\n{trimmed_str}\n```" 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. @@ -672,11 +542,11 @@ def guide_with_numbers(board_str: str) -> str: # Spaces and newlines would mess up the loop because they add extra indexes # between pieces. Newlines are added later by the loop and spaces are added # back in at the end. - board_without_whitespace_str = board_str.replace(' ', '').replace('\n', '') + board_without_whitespace_str = board_str.replace(" ", "").replace("\n", "") # The first number, 8, needs to be added first because it comes before a # newline. From then on, numbers are inserted at newlines. - row_list = list('8' + board_without_whitespace_str) + row_list = list("8" + board_without_whitespace_str) for i, char in enumerate(row_list): # `(i + 1) % 10 == 0` if it is the end of a row, i.e., the 10th column @@ -693,19 +563,18 @@ def guide_with_numbers(board_str: str) -> str: # the newline isn't counted by the loop. If they were split into 3, # or combined into just 1 string, the counter would become off # because it would be counting what is really 2 rows as 3 or 1. - row_list[i:i] = [str(row_num) + '\n', str(row_num - 1)] + row_list[i:i] = [str(row_num) + "\n", str(row_num - 1)] # 1 is appended to the end because it isn't created in the loop, and lines # that begin with spaces have their spaces removed for aesthetics. - row_str = (' '.join(row_list) + ' 1').replace('\n ', '\n') + 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.' @@ -717,24 +586,25 @@ def replace_with_unicode(board_str: str) -> str: """ replaced_str = board_str - replaced_str = replaced_str.replace('P', '♙') - replaced_str = replaced_str.replace('N', '♘') - replaced_str = replaced_str.replace('B', '♗') - replaced_str = replaced_str.replace('R', '♖') - replaced_str = replaced_str.replace('Q', '♕') - replaced_str = replaced_str.replace('K', '♔') + replaced_str = replaced_str.replace("P", "♙") + replaced_str = replaced_str.replace("N", "♘") + replaced_str = replaced_str.replace("B", "♗") + replaced_str = replaced_str.replace("R", "♖") + replaced_str = replaced_str.replace("Q", "♕") + replaced_str = replaced_str.replace("K", "♔") - replaced_str = replaced_str.replace('p', '♟') - replaced_str = replaced_str.replace('n', '♞') - replaced_str = replaced_str.replace('b', '♝') - replaced_str = replaced_str.replace('r', '♜') - replaced_str = replaced_str.replace('q', '♛') - replaced_str = replaced_str.replace('k', '♚') + replaced_str = replaced_str.replace("p", "♟") + replaced_str = replaced_str.replace("n", "♞") + replaced_str = replaced_str.replace("b", "♝") + replaced_str = replaced_str.replace("r", "♜") + replaced_str = replaced_str.replace("q", "♛") + replaced_str = replaced_str.replace("k", "♚") - replaced_str = replaced_str.replace('.', '·') + replaced_str = replaced_str.replace(".", "·") return replaced_str + def trim_whitespace_before_newline(str_to_trim: str) -> str: """Removes any spaces before a newline in a string. @@ -743,4 +613,4 @@ def trim_whitespace_before_newline(str_to_trim: str) -> str: Returns: The trimmed string. """ - return re.sub(r'\s+$', '', str_to_trim, flags=re.M) + return re.sub(r"\s+$", "", str_to_trim, flags=re.M) diff --git a/zulip_bots/zulip_bots/bots/chessbot/test_chessbot.py b/zulip_bots/zulip_bots/bots/chessbot/test_chessbot.py index 113a9e40d4..03ff05780a 100644 --- a/zulip_bots/zulip_bots/bots/chessbot/test_chessbot.py +++ b/zulip_bots/zulip_bots/bots/chessbot/test_chessbot.py @@ -1,9 +1,10 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestChessBot(BotTestCase, DefaultTests): bot_name = "chessbot" - START_RESPONSE = '''New game! The board looks like this: + START_RESPONSE = """New game! The board looks like this: ``` a b c d e f g h @@ -26,9 +27,9 @@ class TestChessBot(BotTestCase, DefaultTests): ```do ``` -*Remember to @-mention Chess Bot at the beginning of your response.*''' +*Remember to @-mention Chess Bot at the beginning of your response.*""" - DO_E4_RESPONSE = '''The board was like this: + DO_E4_RESPONSE = """The board was like this: ``` h g f e d c b a @@ -67,9 +68,9 @@ class TestChessBot(BotTestCase, DefaultTests): ```do ``` -*Remember to @-mention Chess Bot at the beginning of your response.*''' +*Remember to @-mention Chess Bot at the beginning of your response.*""" - DO_KE4_RESPONSE = '''Sorry, the move *Ke4* isn't legal. + DO_KE4_RESPONSE = """Sorry, the move *Ke4* isn't legal. ``` h g f e d c b a @@ -89,9 +90,9 @@ class TestChessBot(BotTestCase, DefaultTests): ```do ``` -*Remember to @-mention Chess Bot at the beginning of your response.*''' +*Remember to @-mention Chess Bot at the beginning of your response.*""" - RESIGN_RESPONSE = '''*Black* resigned. **White** wins! + RESIGN_RESPONSE = """*Black* resigned. **White** wins! ``` h g f e d c b a @@ -104,18 +105,20 @@ class TestChessBot(BotTestCase, DefaultTests): 7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7 8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8 h g f e d c b a -```''' +```""" def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info({'stockfish_location': '/foo/bar'}): - response = self.get_response(dict(content='')) - self.assertIn('play chess', response['content']) + with self.mock_config_info({"stockfish_location": "/foo/bar"}): + response = self.get_response(dict(content="")) + self.assertIn("play chess", response["content"]) 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), - ]) + 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), + ] + ) 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 8bd983ff1e..4a653a8253 100644 --- a/zulip_bots/zulip_bots/bots/connect_four/connect_four.py +++ b/zulip_bots/zulip_bots/bots/connect_four/connect_four.py @@ -1,24 +1,25 @@ -from zulip_bots.game_handler import GameAdapter -from zulip_bots.bots.connect_four.controller import ConnectFourModel from typing import Any +from zulip_bots.bots.connect_four.controller import ConnectFourModel +from zulip_bots.game_handler import GameAdapter + class ConnectFourMessageHandler: - tokens = [':blue_circle:', ':red_circle:'] + tokens = [":blue_circle:", ":red_circle:"] def parse_board(self, board: Any) -> str: # Header for the top of the board - board_str = ':one: :two: :three: :four: :five: :six: :seven:' + board_str = ":one: :two: :three: :four: :five: :six: :seven:" for row in range(0, 6): - board_str += '\n\n' + board_str += "\n\n" for column in range(0, 7): if board[row][column] == 0: - board_str += ':white_circle: ' + board_str += ":white_circle: " elif board[row][column] == 1: - board_str += self.tokens[0] + ' ' + board_str += self.tokens[0] + " " elif board[row][column] == -1: - board_str += self.tokens[1] + ' ' + board_str += self.tokens[1] + " " return board_str @@ -26,31 +27,33 @@ def get_player_color(self, turn: int) -> str: return self.tokens[turn] def alert_move_message(self, original_player: str, move_info: str) -> str: - column_number = move_info.replace('move ', '') - return original_player + ' moved in column ' + column_number + column_number = move_info.replace("move ", "") + return original_player + " moved in column " + column_number def game_start_message(self) -> str: - return 'Type `move ` or `` to place a token.\n\ -The first player to get 4 in a row wins!\n Good Luck!' + return "Type `move ` or `` to place a token.\n\ +The first player to get 4 in a row wins!\n Good Luck!" class ConnectFourBotHandler(GameAdapter): - ''' + """ Bot that uses the Game Adapter class to allow users to play other users or the comptuer in a game of Connect Four - ''' + """ 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_regex = '(move ([1-7])$)|(([1-7])$)' + game_name = "Connect Four" + bot_name = "connect_four" + 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 - rules = '''Try to get four pieces in row, Diagonals count too!''' + rules = """Try to get four pieces in row, Diagonals count too!""" super().__init__( game_name, @@ -60,7 +63,7 @@ def __init__(self) -> None: 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 3120f1b2aa..eed70b4327 100644 --- a/zulip_bots/zulip_bots/bots/connect_four/controller.py +++ b/zulip_bots/zulip_bots/bots/connect_four/controller.py @@ -1,13 +1,14 @@ from copy import deepcopy from functools import reduce + from zulip_bots.game_handler import BadMoveException class ConnectFourModel: - ''' + """ Object that manages running the Connect Four logic for the Connect Four Bot - ''' + """ def __init__(self): self.blank_board = [ @@ -16,7 +17,7 @@ def __init__(self): [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 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 @@ -26,10 +27,7 @@ def update_board(self, board): 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: @@ -56,11 +54,11 @@ def make_move(self, move, player_number, is_computer=False): token_number = 1 finding_move = True row = 5 - column = int(move.replace('move ', '')) - 1 + column = int(move.replace("move ", "")) - 1 while finding_move: if row < 0: - raise BadMoveException('Make sure your move is in a column with free space.') + raise BadMoveException("Make sure your move is in a column with free space.") if self.current_board[row][column] == 0: self.current_board[row][column] = token_number finding_move = False @@ -75,8 +73,12 @@ def get_horizontal_wins(board): 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: @@ -89,8 +91,12 @@ def get_vertical_wins(board): 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: @@ -105,8 +111,12 @@ def get_diagonal_wins(board): # 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: @@ -115,8 +125,12 @@ def get_diagonal_wins(board): # 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: @@ -129,15 +143,17 @@ def get_diagonal_wins(board): top_row_multiple = reduce(lambda x, y: x * y, self.current_board[0]) if top_row_multiple != 0: - return 'draw' + 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 elif winner == -1: return second_player - return '' + return "" 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 3e091b9ba3..48a547b4ca 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 @@ -1,34 +1,33 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests +from typing import Dict, List from zulip_bots.bots.connect_four.connect_four import ConnectFourModel from zulip_bots.game_handler import BadMoveException -from typing import Dict, List +from zulip_bots.test_lib import BotTestCase, DefaultTests class TestConnectFourBot(BotTestCase, DefaultTests): - bot_name = 'connect_four' + 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 for multiple responses to be validated, and for mocking of the bot's internal data - ''' + """ bot, bot_handler = self._get_handlers() message = self.make_request_message(request, user) @@ -36,17 +35,13 @@ def verify_response(self, request: str, expected_response: str, response_number: 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.assertEqual(expected_response, first_response["content"]) def help_message(self) -> str: - return '''** Connect Four Bot Help:** + return """** Connect Four Bot Help:** *Preface all commands with @**test-bot*** * To start a game in a stream (*recommended*), type `start game` @@ -67,13 +62,15 @@ def help_message(self) -> str: * To see rules of this game, type `rules` * To make your move during a game, type -```move ``` or ``````''' +```move ``` or ``````""" def test_static_responses(self) -> None: - self.verify_response('help', self.help_message(), 0) + 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: \ @@ -85,17 +82,19 @@ def test_game_message_handler_responses(self) -> None: :blue_circle: :red_circle: :white_circle: :white_circle: :white_circle: \ :white_circle: :white_circle: \n\n\ :blue_circle: :red_circle: :white_circle: :white_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.alert_move_message("foo", "move 6"), "foo moved in column 6" + ) 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.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 @@ def test_game_message_handler_responses(self) -> None: [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 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 @@ def test_game_message_handler_responses(self) -> None: [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 @@ def test_game_message_handler_responses(self) -> None: [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,27 +139,25 @@ def confirmMove( 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) def confirmWinStates(array: List[List[List[List[int]]]]) -> None: for board in array[0]: - confirmGameOver(board, 'first_player') + confirmGameOver(board, "first_player") for board in array[1]: - confirmGameOver(board, 'second_player') + confirmGameOver(board, "second_player") connectFourModel = ConnectFourModel() @@ -170,7 +168,8 @@ def confirmWinStates(array: List[List[List[List[int]]]]) -> None: [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 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 @@ def confirmWinStates(array: List[List[List[List[int]]]]) -> None: [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 @@ def confirmWinStates(array: List[List[List[List[int]]]]) -> None: [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 @@ def confirmWinStates(array: List[List[List[List[int]]]]) -> None: [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 @@ def confirmWinStates(array: List[List[List[List[int]]]]) -> None: # 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 @@ def confirmWinStates(array: List[List[List[List[int]]]]) -> None: # 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,73 +440,121 @@ def confirmWinStates(array: List[List[List[List[int]]]]) -> None: 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, 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(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(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(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( + 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, + 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( + 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( + 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], + ], + ) # Test Game Over Logic: - confirmGameOver(blank_board, '') - confirmGameOver(full_board, 'draw') + confirmGameOver(blank_board, "") + confirmGameOver(full_board, "draw") # Test Win States: confirmWinStates(horizontal_win_boards) @@ -483,7 +564,7 @@ def confirmWinStates(array: List[List[List[List[int]]]]) -> None: def test_more_logic(self) -> None: model = ConnectFourModel() - move = 'move 4' + move = "move 4" col = 3 # zero-indexed self.assertEqual(model.get_column(col), [0, 0, 0, 0, 0, 0]) diff --git a/zulip_bots/zulip_bots/bots/converter/converter.py b/zulip_bots/zulip_bots/bots/converter/converter.py index bc0412af17..815d815dc0 100644 --- a/zulip_bots/zulip_bots/bots/converter/converter.py +++ b/zulip_bots/zulip_bots/bots/converter/converter.py @@ -1,13 +1,13 @@ # See readme.md for instructions on running this code. import copy -from math import log10, floor +from math import floor, log10 +from typing import Any, Dict, List from zulip_bots.bots.converter import utils - -from typing import Any, Dict, List from zulip_bots.lib import BotHandler + def is_float(value: Any) -> bool: try: float(value) @@ -15,27 +15,30 @@ 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: - ''' + """ This plugin allows users to make conversions between various units, e.g. Celsius to Fahrenheit, or kilobytes to gigabytes. It looks for messages of the format '@mention-bot ' The message '@mention-bot help' posts a short description of how to use the plugin, along with a list of all supported units. - ''' + """ def usage(self) -> str: - return ''' + return """ This plugin allows users to make conversions between various units, e.g. Celsius to Fahrenheit, or kilobytes to gigabytes. It looks for messages of @@ -43,14 +46,15 @@ def usage(self) -> str: The message '@mention-bot help' posts a short description of how to use the plugin, along with a list of all supported units. - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: 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'] + content = message["content"] words = content.lower().split() convert_indexes = [i for i, word in enumerate(words) if word == "@convert"] @@ -58,7 +62,7 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) results = [] for convert_index in convert_indexes: - if (convert_index + 1) < len(words) and words[convert_index + 1] == 'help': + if (convert_index + 1) < len(words) and words[convert_index + 1] == "help": results.append(utils.HELP_MESSAGE) continue if (convert_index + 3) < len(words): @@ -68,7 +72,7 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) exponent = 0 if not is_float(number): - results.append('`' + number + '` is not a valid number. ' + utils.QUICK_HELP) + results.append("`" + number + "` is not a valid number. " + utils.QUICK_HELP) continue # cannot reassign "number" as a float after using as string, so changed name @@ -78,27 +82,32 @@ 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] if not uf_to_std: - results.append('`' + unit_from + '` is not a valid unit. ' + utils.QUICK_HELP) + results.append("`" + unit_from + "` is not a valid unit. " + utils.QUICK_HELP) if not ut_to_std: - results.append('`' + unit_to + '` is not a valid unit.' + utils.QUICK_HELP) + results.append("`" + unit_to + "` is not a valid unit." + utils.QUICK_HELP) if not uf_to_std or not ut_to_std: continue base_unit = uf_to_std[2] if uf_to_std[2] != ut_to_std[2]: - unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from + 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 @@ -108,24 +117,26 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) number_res -= ut_to_std[0] number_res /= ut_to_std[1] - if base_unit == 'bit': + if base_unit == "bit": number_res *= 1024 ** (exponent // 3) else: 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) + results.append("Too few arguments given. " + utils.QUICK_HELP) - new_content = '' + new_content = "" for idx, result in enumerate(results, 1): - new_content += ((str(idx) + '. conversion: ') if len(results) > 1 else '') + result + '\n' + new_content += ((str(idx) + ". conversion: ") if len(results) > 1 else "") + result + "\n" 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 4d538768f3..06923328b5 100755 --- a/zulip_bots/zulip_bots/bots/converter/test_converter.py +++ b/zulip_bots/zulip_bots/bots/converter/test_converter.py @@ -1,31 +1,47 @@ +from zulip_bots.bots.converter import utils from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.bots.converter import utils class TestConverterBot(BotTestCase, DefaultTests): bot_name = "converter" 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 aa2a2a93cd..3a32f8c34d 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.' +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 86c13beefe..073afb2e1e 100644 --- a/zulip_bots/zulip_bots/bots/define/define.py +++ b/zulip_bots/zulip_bots/bots/define/define.py @@ -1,38 +1,40 @@ # See readme.md for instructions on running this code. import logging -import requests -import html2text import string - from typing import Dict + +import html2text +import requests + from zulip_bots.lib import BotHandler + class DefineHandler: - ''' + """ This plugin define a word that the user inputs. It looks for messages starting with '@mention-bot'. - ''' + """ - DEFINITION_API_URL = 'https://owlbot.info/api/v2/dictionary/{}?format=json' - REQUEST_ERROR_MESSAGE = 'Could not load definition.' - EMPTY_WORD_REQUEST_ERROR_MESSAGE = 'Please enter a word to define.' - PHRASE_ERROR_MESSAGE = 'Definitions for phrases are not available.' - SYMBOLS_PRESENT_ERROR_MESSAGE = 'Definitions of words with symbols are not possible.' + DEFINITION_API_URL = "https://owlbot.info/api/v2/dictionary/{}?format=json" + REQUEST_ERROR_MESSAGE = "Could not load definition." + EMPTY_WORD_REQUEST_ERROR_MESSAGE = "Please enter a word to define." + PHRASE_ERROR_MESSAGE = "Definitions for phrases are not available." + SYMBOLS_PRESENT_ERROR_MESSAGE = "Definitions of words with symbols are not possible." def usage(self) -> str: - return ''' + return """ This plugin will allow users to define a word. Users should preface messages with @mention-bot. - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - original_content = message['content'].strip() + original_content = message["content"].strip() bot_response = self.get_bot_define_response(original_content) bot_handler.send_reply(message, bot_response) def get_bot_define_response(self, original_content: str) -> str: - split_content = original_content.split(' ') + split_content = original_content.split(" ") # If there are more than one word (a phrase) if len(split_content) > 1: return DefineHandler.PHRASE_ERROR_MESSAGE @@ -49,7 +51,7 @@ def get_bot_define_response(self, original_content: str) -> str: if not to_define_lower: return self.EMPTY_WORD_REQUEST_ERROR_MESSAGE else: - response = '**{}**:\n'.format(to_define) + response = f"**{to_define}**:\n" try: # Use OwlBot API to fetch definition. @@ -63,8 +65,10 @@ def get_bot_define_response(self, original_content: str) -> str: else: # Definitions available. # 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)) + example = d["example"] if d["example"] else "*No example available.*" + response += "\n" + "* (**{}**) {}\n  {}".format( + d["type"], d["definition"], html2text.html2text(example) + ) except Exception: response += self.REQUEST_ERROR_MESSAGE @@ -72,4 +76,5 @@ def get_bot_define_response(self, original_content: str) -> str: 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 fcf70d979a..62a1deb048 100755 --- a/zulip_bots/zulip_bots/bots/define/test_define.py +++ b/zulip_bots/zulip_bots/bots/define/test_define.py @@ -1,54 +1,57 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests from unittest.mock import patch +from zulip_bots.test_lib import BotTestCase, DefaultTests + + class TestDefineBot(BotTestCase, DefaultTests): bot_name = "define" 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") - with self.mock_http_conversation('test_single_type_word'): - self.verify_reply('cat', bot_response) + 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") - with self.mock_http_conversation('test_multi_type_word'): - self.verify_reply('help', bot_response) + 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) # Incorrect word. bot_response = "**foo**:\nCould not load definition." - with self.mock_http_conversation('test_incorrect_word'): - self.verify_reply('foo', bot_response) + with self.mock_http_conversation("test_incorrect_word"): + self.verify_reply("foo", bot_response) # Phrases are not defined. No request is sent to the Internet. bot_response = "Definitions for phrases are not available." - self.verify_reply('The sky is blue', bot_response) + self.verify_reply("The sky is blue", bot_response) # Symbols are considered invalid for words bot_response = "Definitions of words with symbols are not possible." - self.verify_reply('#', bot_response) + self.verify_reply("#", bot_response) # Empty messages are returned with a prompt to reply. No request is sent to the Internet. bot_response = "Please enter a word to define." - self.verify_reply('', bot_response) + 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 f442da64f3..592180ba0f 100644 --- a/zulip_bots/zulip_bots/bots/dialogflow/dialogflow.py +++ b/zulip_bots/zulip_bots/bots/dialogflow/dialogflow.py @@ -1,57 +1,62 @@ # See readme.md for instructions on running this code. -import logging import json +import logging +from typing import Dict import apiai -from typing import Dict from zulip_bots.lib import BotHandler -help_message = '''DialogFlow bot +help_message = """DialogFlow bot 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'] - ai = apiai.ApiAI(config['key']) + if message_content.strip() == "" or message_content.strip() == "help": + return config["bot_info"] + ai = apiai.ApiAI(config["key"]) try: request = ai.text_request() request.session_id = sender_id request.query = message_content response = request.getresponse() - res_str = response.read().decode('utf8', 'ignore') + 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']) - if res_json['result']['fulfillment']['speech'] == '': - if 'alternateResult' in res_json.keys(): - if res_json['alternateResult']['fulfillment']['speech'] != '': - return res_json['alternateResult']['fulfillment']['speech'] - return 'Error. No result.' - return res_json['result']['fulfillment']['speech'] + if res_json["status"]["errorType"] != "success" and "result" not in res_json.keys(): + 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"] != "": + return res_json["alternateResult"]["fulfillment"]["speech"] + return "Error. No result." + return res_json["result"]["fulfillment"]["speech"] except Exception as e: logging.exception(str(e)) - return 'Error. {}.'.format(str(e)) + return f"Error. {str(e)}." + class DialogFlowHandler: - ''' + """ This plugin allows users to easily add their own DialogFlow bots to zulip - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('dialogflow') + self.config_info = bot_handler.get_config_info("dialogflow") def usage(self) -> str: - return ''' + return """ This plugin will allow users to easily add their own DialogFlow bots to zulip - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - result = get_bot_result(message['content'], self.config_info, message['sender_id']) + 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 c27e44bd08..faac3d64ce 100644 --- a/zulip_bots/zulip_bots/bots/dialogflow/test_dialogflow.py +++ b/zulip_bots/zulip_bots/bots/dialogflow/test_dialogflow.py @@ -1,21 +1,20 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests, read_bot_fixture_data - +import json from contextlib import contextmanager - +from typing import ByteString, Iterator from unittest.mock import patch -from typing import Iterator, ByteString +from zulip_bots.test_lib import BotTestCase, DefaultTests, read_bot_fixture_data -import json -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 = "" @@ -24,50 +23,53 @@ def __init__(self) -> None: 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) try: - response_data['request'] - df_response = response_data['response'] + response_data["request"] + df_response = response_data["response"] except KeyError: print("ERROR: 'request' or 'response' field not found in fixture.") raise - with patch('apiai.ApiAI.text_request') as mock_text_request: + with patch("apiai.ApiAI.text_request") as mock_text_request: request = MockTextRequest() request.response = df_response mock_text_request.return_value = request yield + class TestDialogFlowBot(BotTestCase, DefaultTests): - bot_name = 'dialogflow' + 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: - self._test('test_normal', 'hello', 'how are you?') + self._test("test_normal", "hello", "how are you?") def test_403(self) -> None: - self._test('test_403', 'hello', 'Error 403: Access Denied.') + self._test("test_403", "hello", "Error 403: Access Denied.") def test_empty_response(self) -> None: - self._test('test_empty_response', 'hello', 'Error. No result.') + self._test("test_empty_response", "hello", "Error. No result.") def test_exception(self) -> None: - with patch('logging.exception'): - self._test('test_exception', 'hello', 'Error. \'status\'.') + with patch("logging.exception"): + self._test("test_exception", "hello", "Error. 'status'.") def test_help(self) -> None: - self._test('test_normal', 'help', 'bot info foo bar') - self._test('test_normal', '', 'bot info foo bar') + self._test("test_normal", "help", "bot info foo bar") + self._test("test_normal", "", "bot info foo bar") def test_alternate_response(self) -> None: - self._test('test_alternate_result', 'hello', 'alternate result') + self._test("test_alternate_result", "hello", "alternate result") def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info({'key': 'abcdefg', 'bot_info': 'bot info foo bar'}): + with self.mock_config_info({"key": "abcdefg", "bot_info": "bot info foo bar"}): pass 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 ff1d09e4f8..930ac0ea4e 100644 --- a/zulip_bots/zulip_bots/bots/dropbox_share/dropbox_share.py +++ b/zulip_bots/zulip_bots/bots/dropbox_share/dropbox_share.py @@ -1,33 +1,37 @@ -from dropbox import Dropbox +import re from typing import Any, Dict, List, Tuple + +from dropbox import Dropbox + from zulip_bots.lib import BotHandler -import re URL = "[{name}](https://www.dropbox.com/home{path})" + class DropboxHandler: - ''' + """ This bot allows you to easily share, search and upload files between zulip and your dropbox account. - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('dropbox_share') - self.ACCESS_TOKEN = self.config_info.get('access_token') + self.config_info = bot_handler.get_config_info("dropbox_share") + self.ACCESS_TOKEN = self.config_info.get("access_token") self.client = Dropbox(self.ACCESS_TOKEN) def usage(self) -> str: return get_help() def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - command = message['content'] + command = message["content"] if command == "": command = "help" msg = dbx_command(self.client, command) bot_handler.send_reply(message, msg) + def get_help() -> str: - return ''' + return """ Example commands: ``` @@ -40,10 +44,11 @@ def get_help() -> str: @mention-bot search: search a file/folder @mention-bot share: get a shareable link for the file/folder ``` - ''' + """ + def get_usage_examples() -> str: - return ''' + return """ Usage: ``` @dropbox ls - Shows files/folders in the root folder. @@ -57,79 +62,89 @@ def get_usage_examples() -> str: @dropbox search boo --mr 10 - Search for boo and get at max 10 results. @dropbox search boo --fd foo - Search for boo in folder foo. ``` - ''' + """ + 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+))?' + 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+))?", ) + def get_commands() -> Dict[str, Tuple[Any, List[str]]]: return { - 'help': (dbx_help, ['command']), - 'ls': (dbx_ls, ['optional_path']), - 'mkdir': (dbx_mkdir, ['path']), - 'rm': (dbx_rm, ['path']), - 'write': (dbx_write, ['path', 'some_text']), - 'read': (dbx_read, ['path']), - 'search': (dbx_search, ['some_text', 'folder', 'max_results']), - 'share': (dbx_share, ['path']), - 'usage': (dbx_usage, []), + "help": (dbx_help, ["command"]), + "ls": (dbx_ls, ["optional_path"]), + "mkdir": (dbx_mkdir, ["path"]), + "rm": (dbx_rm, ["path"]), + "write": (dbx_write, ["path", "some_text"]), + "read": (dbx_read, ["path"]), + "search": (dbx_search, ["some_text", "folder", "max_results"]), + "share": (dbx_share, ["path"]), + "usage": (dbx_usage, []), } + def dbx_command(client: Any, cmd: str) -> str: cmd = cmd.strip() - if cmd == 'help': + 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() + return "ERROR: unrecognized command\n" + get_help() f, arg_names = commands[cmd_name] partial_regexes = [REGEXES[a] for a in arg_names] - regex = ' '.join(partial_regexes) - regex += '$' + regex = " ".join(partial_regexes) + regex += "$" m = re.match(regex, cmd_args) if m: return f(client, *m.groups()) else: - return 'ERROR: ' + syntax_help(cmd_name) + return "ERROR: " + syntax_help(cmd_name) + def syntax_help(cmd_name: str) -> str: commands = get_commands() f, arg_names = commands[cmd_name] - arg_syntax = ' '.join('<' + a + '>' for a in arg_names) + arg_syntax = " ".join("<" + a + ">" for a in arg_names) if arg_syntax: - cmd = cmd_name + ' ' + arg_syntax + cmd = cmd_name + " " + arg_syntax else: cmd = cmd_name - return 'syntax: {}'.format(cmd) + return f"syntax: {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 + 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 + if fn != "": + fn = "/" + fn try: result = client.files_list_folder(fn) @@ -137,30 +152,36 @@ def dbx_ls(client: Any, fn: str) -> str: for meta in result.entries: files_list += [" - " + URL.format(name=meta.name, path=meta.path_lower)] - msg = '\n'.join(files_list) - if msg == '': - msg = '`No files available`' + msg = "\n".join(files_list) + if msg == "": + 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 + fn = "/" + fn try: 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 + fn = "/" + fn try: result = client.files_upload(content.encode(), fn) @@ -170,24 +191,28 @@ def dbx_write(client: Any, fn: str, content: str) -> str: return msg + def dbx_read(client: Any, fn: str) -> str: - fn = '/' + fn + fn = "/" + fn try: result = client.files_download(fn) - msg = "**{}** :\n{}".format(result[0].name, result[1].text) + msg = f"**{result[0].name}** :\n{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 = '' + folder = "" else: - folder = '/' + folder + folder = "/" + folder if max_results is None: - max_results = '20' + max_results = "20" try: result = client.files_search(folder, query, max_results=int(max_results)) msg_list = [] @@ -196,22 +221,27 @@ def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str: file_info = entry.metadata count += 1 msg_list += [" - " + URL.format(name=file_info.name, path=file_info.path_lower)] - msg = '\n'.join(msg_list) + 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." - - 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 = ( + "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”)." + ) return msg + def dbx_share(client: Any, fn: str): - fn = '/' + fn + fn = "/" + fn try: result = client.sharing_create_shared_link(fn) msg = result.url @@ -220,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 8b51056603..d1fead3b1a 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 @@ -1,63 +1,63 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests from unittest.mock import patch from zulip_bots.bots.dropbox_share.test_util import ( MockFileMetadata, + MockHttpResponse, MockListFolderResult, + MockPathLinkMetadata, MockSearchMatch, MockSearchResult, - MockPathLinkMetadata, - MockHttpResponse ) +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 = [ - MockFileMetadata('moo', '/foo/moo'), - MockFileMetadata('noo', '/foo/noo'), + 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') + return MockFileMetadata("foo", "/foo") + def download_file(*args, **kwargs): - return [MockFileMetadata('foo', '/foo'), MockHttpResponse('boo')] + 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') + return MockPathLinkMetadata("http://www.foo.com/boo") + def get_help() -> str: - return ''' + return """ Example commands: ``` @@ -70,7 +70,8 @@ def get_help() -> str: @mention-bot search: search a file/folder @mention-bot share: get a shareable link for the file/folder ``` - ''' + """ + class TestDropboxBot(BotTestCase, DefaultTests): bot_name = "dropbox_share" @@ -78,129 +79,164 @@ class TestDropboxBot(BotTestCase, DefaultTests): def test_bot_responds_to_empty_message(self): with self.mock_config_info(self.config_info): - self.verify_reply('', get_help()) - self.verify_reply('help', get_help()) + self.verify_reply("", get_help()) + 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): + 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): 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): - self.verify_reply('mkdir foo', bot_response) + 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): - self.verify_reply('mkdir foo/bar', bot_response) + 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): - self.verify_reply('rm foo', bot_response) + 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): - self.verify_reply('rm foo', bot_response) + 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): - self.verify_reply('write foo boo', bot_response) + 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): - self.verify_reply('write foo boo', bot_response) + 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): - self.verify_reply('read foo', bot_response) + 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): - self.verify_reply('read foo', bot_response) + 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): - self.verify_reply('search foo', bot_response) + 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): - self.verify_reply('search boo --fd foo', bot_response) + 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): - self.verify_reply('search foo', bot_response) + 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): - self.verify_reply('share boo', bot_response) + 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): + 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): - self.verify_reply('share boo', bot_response) + 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): - bot_response = 'syntax: ls ' + bot_response = "syntax: ls " with self.mock_config_info(self.config_info): - self.verify_reply('help ls', bot_response) + self.verify_reply("help ls", bot_response) def test_dbx_usage(self): - bot_response = ''' + bot_response = """ Usage: ``` @dropbox ls - Shows files/folders in the root folder. @@ -214,9 +250,9 @@ def test_dbx_usage(self): @dropbox search boo --mr 10 - Search for boo and get at max 10 results. @dropbox search boo --fd foo - Search for boo in folder foo. ``` - ''' + """ with self.mock_config_info(self.config_info): - self.verify_reply('usage', bot_response) + self.verify_reply("usage", bot_response) def test_invalid_commands(self): ls_error_response = "ERROR: syntax: ls " @@ -241,7 +277,7 @@ def test_invalid_commands(self): self.verify_reply("usage foo", usage_error_response) def test_unkown_command(self): - bot_response = '''ERROR: unrecognized command + bot_response = """ERROR: unrecognized command Example commands: @@ -255,6 +291,6 @@ def test_unkown_command(self): @mention-bot search: search a file/folder @mention-bot share: get a shareable link for the file/folder ``` - ''' + """ with self.mock_config_info(self.config_info): - self.verify_reply('unknown command', bot_response) + self.verify_reply("unknown command", bot_response) 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 77e97c8f0b..2a30d4bc1f 100644 --- a/zulip_bots/zulip_bots/bots/dropbox_share/test_util.py +++ b/zulip_bots/zulip_bots/bots/dropbox_share/test_util.py @@ -1,27 +1,33 @@ from typing import List + class MockFileMetadata: def __init__(self, name: str, path_lower: str): 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 11000c0555..9d08a0440d 100755 --- a/zulip_bots/zulip_bots/bots/encrypt/encrypt.py +++ b/zulip_bots/zulip_bots/bots/encrypt/encrypt.py @@ -1,13 +1,15 @@ from typing import Dict + from zulip_bots.lib import BotHandler + def encrypt(text: str) -> str: # This is where the actual ROT13 is applied # WHY IS .JOIN NOT WORKING?! textlist = list(text) - newtext = '' - firsthalf = 'abcdefghijklmABCDEFGHIJKLM' - lasthalf = 'nopqrstuvwxyzNOPQRSTUVWXYZ' + newtext = "" + firsthalf = "abcdefghijklmABCDEFGHIJKLM" + lasthalf = "nopqrstuvwxyzNOPQRSTUVWXYZ" for char in textlist: if char in firsthalf: newtext += lasthalf[firsthalf.index(char)] @@ -18,27 +20,29 @@ def encrypt(text: str) -> str: return newtext + class EncryptHandler: - ''' + """ This bot allows users to quickly encrypt messages using ROT13 encryption. It encrypts/decrypts messages starting with @mention-bot. - ''' + """ def usage(self) -> str: - return ''' + return """ This bot uses ROT13 encryption for its purposes. It responds to me starting with @mention-bot. Feeding encrypted messages into the bot decrypts them. - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: bot_response = self.get_bot_encrypt_response(message) bot_handler.send_reply(message, bot_response) def get_bot_encrypt_response(self, message: Dict[str, str]) -> str: - original_content = message['content'] + original_content = message["content"] temp_content = encrypt(original_content) send_content = "Encrypted/Decrypted text: " + temp_content return send_content + handler_class = EncryptHandler diff --git a/zulip_bots/zulip_bots/bots/encrypt/test_encrypt.py b/zulip_bots/zulip_bots/bots/encrypt/test_encrypt.py index 07a32f34fd..93a2c104f9 100755 --- a/zulip_bots/zulip_bots/bots/encrypt/test_encrypt.py +++ b/zulip_bots/zulip_bots/bots/encrypt/test_encrypt.py @@ -1,12 +1,13 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestEncryptBot(BotTestCase, DefaultTests): bot_name = "encrypt" def test_bot(self) -> None: dialog = [ ("", "Encrypted/Decrypted text: "), - ("Let\'s Do It", "Encrypted/Decrypted text: Yrg\'f Qb Vg"), + ("Let's Do It", "Encrypted/Decrypted text: Yrg'f Qb Vg"), ("me&mom together..!!", "Encrypted/Decrypted text: zr&zbz gbtrgure..!!"), ("foo bar", "Encrypted/Decrypted text: sbb one"), ("Please encrypt this", "Encrypted/Decrypted text: Cyrnfr rapelcg guvf"), 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 c73bef143c..96908037b3 100644 --- a/zulip_bots/zulip_bots/bots/file_uploader/file_uploader.py +++ b/zulip_bots/zulip_bots/bots/file_uploader/file_uploader.py @@ -1,42 +1,44 @@ +import os +from pathlib import Path from typing import Dict + from zulip_bots.lib import BotHandler -import os -from pathlib import Path class FileUploaderHandler: def usage(self) -> str: return ( - 'This interactive bot is used to upload files (such as images) to the Zulip server:' - '\n- @uploader : Upload a file, where is the path to the file' - '\n- @uploader help : Display help message' + "This interactive bot is used to upload files (such as images) to the Zulip server:" + "\n- @uploader : Upload a file, where is the path to the file" + "\n- @uploader help : Display help message" ) def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: HELP_STR = ( - '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' + "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" ) - content = message['content'].strip() - if content == 'help': + content = message["content"].strip() + if content == "help": bot_handler.send_reply(message, HELP_STR) return path = Path(os.path.expanduser(content)) if not path.is_file(): - bot_handler.send_reply(message, 'File `{}` not found'.format(content)) + bot_handler.send_reply(message, f"File `{content}` not found") return path = path.resolve() upload = bot_handler.upload_file_from_path(str(path)) - if upload['result'] != 'success': - msg = upload['msg'] - bot_handler.send_reply(message, 'Failed to upload `{}` file: {}'.format(path, msg)) + if upload["result"] != "success": + msg = upload["msg"] + bot_handler.send_reply(message, f"Failed to upload `{path}` file: {msg}") return - uploaded_file_reply = '[{}]({})'.format(path.name, upload['uri']) + 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 1498c53585..6d84fecb3b 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 @@ -1,37 +1,42 @@ +from pathlib import Path from unittest.mock import Mock, patch -from zulip_bots.test_lib import ( - BotTestCase, - DefaultTests, -) +from zulip_bots.test_lib import BotTestCase, DefaultTests -from pathlib import Path class TestFileUploaderBot(BotTestCase, DefaultTests): bot_name = "file_uploader" - @patch('pathlib.Path.is_file', return_value=False) + @patch("pathlib.Path.is_file", return_value=False) def test_file_not_found(self, is_file: Mock) -> None: - self.verify_reply('file.txt', 'File `file.txt` not found') + self.verify_reply("file.txt", "File `file.txt` not found") - @patch('pathlib.Path.resolve', return_value=Path('/file.txt')) - @patch('pathlib.Path.is_file', return_value=True) + @patch("pathlib.Path.resolve", return_value=Path("/file.txt")) + @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())) + 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()) + ) - @patch('pathlib.Path.resolve', return_value=Path('/file.txt')) - @patch('pathlib.Path.is_file', return_value=True) + @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): - self.verify_reply('file.txt', '[file.txt](https://file/uri)') + server_reply = dict(result="success", uri="https://file/uri") + 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 eeb186fc4e..69485e03f7 100644 --- a/zulip_bots/zulip_bots/bots/flock/flock.py +++ b/zulip_bots/zulip_bots/bots/flock/flock.py @@ -1,23 +1,26 @@ import logging +from typing import Any, Dict, List, Optional, Tuple + import requests -from typing import Any, Dict, List, Tuple, Optional -from zulip_bots.lib import BotHandler from requests.exceptions import ConnectionError -USERS_LIST_URL = 'https://api.flock.co/v1/roster.listContacts' -SEND_MESSAGE_URL = 'https://api.flock.co/v1/chat.sendMessage' +from zulip_bots.lib import BotHandler -help_message = ''' +USERS_LIST_URL = "https://api.flock.co/v1/roster.listContacts" +SEND_MESSAGE_URL = "https://api.flock.co/v1/chat.sendMessage" + +help_message = """ You can send messages to any Flock user associated with your account from Zulip. *Syntax*: **@botname to: message** where `to` is **firstName** of recipient. -''' +""" # Matches the recipient name provided by user with list of users in his contacts. # If matches, returns the matched User's ID def find_recipient_id(users: List[Any], recipient_name: str) -> str: for user in users: - if recipient_name == user['firstName']: - return user['id'] + 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) @@ -32,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]]: - token = config['token'] - payload = { - 'token': token - } +def get_recipient_id( + recipient_name: str, config: Dict[str, str] +) -> Tuple[Optional[str], Optional[str]]: + token = config["token"] + payload = {"token": token} users, error = make_flock_request(USERS_LIST_URL, payload) if users is None: return (None, error) @@ -51,10 +55,11 @@ 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'] - content_pieces = content.split(':') + token = config["token"] + content_pieces = content.split(":") recipient_name = content_pieces[0].strip() message = content_pieces[1].strip() @@ -65,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 @@ -79,29 +80,32 @@ 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': + if content == "" or content == "help": return help_message else: result = get_flock_response(content, config) return result + class FlockHandler: - ''' + """ This is flock bot. Now you can send messages to any of your flock user without having to leave Zulip. - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('flock') + self.config_info = bot_handler.get_config_info("flock") def usage(self) -> str: - return '''Hello from Flock Bot. You can send messages to any Flock user -right from Zulip.''' + return """Hello from Flock Bot. You can send messages to any Flock user +right from Zulip.""" def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - response = get_flock_bot_response(message['content'], self.config_info) + 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 6add591b5e..9efb1c8831 100644 --- a/zulip_bots/zulip_bots/bots/flock/test_flock.py +++ b/zulip_bots/zulip_bots/bots/flock/test_flock.py @@ -1,75 +1,84 @@ from unittest.mock import patch -from zulip_bots.test_lib import BotTestCase, DefaultTests + from requests.exceptions import ConnectionError +from zulip_bots.test_lib import BotTestCase, DefaultTests + + 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 = ''' + help_message = """ You can send messages to any Flock user associated with your account from Zulip. *Syntax*: **@botname to: message** where `to` is **firstName** of recipient. -''' +""" def test_bot_responds_to_empty_message(self) -> None: - self.verify_reply('', self.help_message) + self.verify_reply("", self.help_message) def test_help_message(self) -> None: - self.verify_reply('', self.help_message) + 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'): - self.verify_reply('david: hello', bot_response) + 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'): - self.verify_reply('david: hello', bot_response) + 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') + @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'): - self.verify_reply('Rishabh: hi there', bot_response) + 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') + @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'): - self.verify_reply('Rishabh: hi there', bot_response) + 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') + @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'): - self.verify_reply('Rishabh: hi there', bot_response) + 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 2dbbd71559..47c3c54f34 100644 --- a/zulip_bots/zulip_bots/bots/followup/followup.py +++ b/zulip_bots/zulip_bots/bots/followup/followup.py @@ -1,9 +1,11 @@ # See readme.md for instructions on running this code. from typing import Dict + from zulip_bots.lib import BotHandler + class FollowupHandler: - ''' + """ This plugin facilitates creating follow-up tasks when you are using Zulip to conduct a virtual meeting. It looks for messages starting with '@mention-bot'. @@ -12,43 +14,48 @@ class FollowupHandler: Zulip stream called "followup," but this code could be adapted to write follow up items to some kind of external issue tracker as well. - ''' + """ def usage(self) -> str: - return ''' + return """ This plugin will allow users to flag messages as being follow-up items. Users should preface messages with "@mention-bot". Before running this, make sure to create a stream called "followup" that your API user can send to. - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('followup', optional=False) - self.stream = self.config_info.get("stream", 'followup') + self.config_info = bot_handler.get_config_info("followup", optional=False) + self.stream = self.config_info.get("stream", "followup") 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" + if message["content"] == "": + 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': + 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'] - original_sender = message['sender_email'] - temp_content = 'from %s: ' % (original_sender,) + original_content = message["content"] + original_sender = message["sender_email"] + temp_content = f"from {original_sender}: " new_content = temp_content + original_content 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 12f4b93fbb..41d29cef72 100755 --- a/zulip_bots/zulip_bots/bots/followup/test_followup.py +++ b/zulip_bots/zulip_bots/bots/followup/test_followup.py @@ -1,7 +1,4 @@ -from zulip_bots.test_lib import ( - BotTestCase, - DefaultTests, -) +from zulip_bots.test_lib import BotTestCase, DefaultTests class TestFollowUpBot(BotTestCase, DefaultTests): @@ -9,46 +6,48 @@ class TestFollowUpBot(BotTestCase, DefaultTests): def test_followup_stream(self) -> None: message = dict( - content='feed the cat', - type='stream', - sender_email='foo@example.com', + content="feed the cat", + type="stream", + sender_email="foo@example.com", ) - with self.mock_config_info({'stream': 'followup'}): + with self.mock_config_info({"stream": "followup"}): response = self.get_response(message) - self.assertEqual(response['content'], 'from foo@example.com: feed the cat') - self.assertEqual(response['to'], 'followup') + self.assertEqual(response["content"], "from foo@example.com: feed the cat") + self.assertEqual(response["to"], "followup") def test_different_stream(self) -> None: message = dict( - content='feed the cat', - type='stream', - sender_email='foo@example.com', + content="feed the cat", + type="stream", + sender_email="foo@example.com", ) - with self.mock_config_info({'stream': 'issue'}): + with self.mock_config_info({"stream": "issue"}): response = self.get_response(message) - self.assertEqual(response['content'], 'from foo@example.com: feed the cat') - self.assertEqual(response['to'], 'issue') + self.assertEqual(response["content"], "from foo@example.com: feed the cat") + 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) + with self.mock_config_info({"stream": "followup"}): + self.verify_reply("", bot_response) def test_help_text(self) -> None: - request = 'help' - bot_response = ''' + request = "help" + bot_response = """ This plugin will allow users to flag messages as being follow-up items. Users should preface messages with "@mention-bot". Before running this, make sure to create a stream called "followup" that your API user can send to. - ''' + """ - with self.mock_config_info({'stream': 'followup'}): + with self.mock_config_info({"stream": "followup"}): self.verify_reply(request, bot_response) diff --git a/zulip_bots/zulip_bots/bots/front/front.py b/zulip_bots/zulip_bots/bots/front/front.py index ac58c9ad55..2f89c91f7d 100644 --- a/zulip_bots/zulip_bots/bots/front/front.py +++ b/zulip_bots/zulip_bots/bots/front/front.py @@ -1,29 +1,32 @@ -import requests import re from typing import Any, Dict + +import requests + from zulip_bots.lib import BotHandler + class FrontHandler: FRONT_API = "https://api2.frontapp.com/conversations/{}" COMMANDS = [ - ('archive', "Archive a conversation."), - ('delete', "Delete a conversation."), - ('spam', "Mark a conversation as spam."), - ('open', "Restore a conversation."), - ('comment ', "Leave a comment.") + ("archive", "Archive a conversation."), + ("delete", "Delete a conversation."), + ("spam", "Mark a conversation as spam."), + ("open", "Restore a conversation."), + ("comment ", "Leave a comment."), ] - CNV_ID_REGEXP = 'cnv_(?P[0-9a-z]+)' + CNV_ID_REGEXP = "cnv_(?P[0-9a-z]+)" COMMENT_PREFIX = "comment " def usage(self) -> str: - return ''' + return """ Front Bot uses the Front REST API to interact with Front. In order to use Front Bot, `front.conf` must be set up. See `doc.md` for more details. - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: - config = bot_handler.get_config_info('front') - api_key = config.get('api_key') + config = bot_handler.get_config_info("front") + api_key = config.get("api_key") if not api_key: raise KeyError("No API key specified.") @@ -32,14 +35,16 @@ def initialize(self, bot_handler: BotHandler) -> None: def help(self, bot_handler: BotHandler) -> str: response = "" for command, description in self.COMMANDS: - response += "`{}` {}\n".format(command, description) + response += f"`{command}` {description}\n" 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." @@ -47,9 +52,11 @@ def archive(self, bot_handler: BotHandler) -> str: 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." @@ -57,9 +64,11 @@ def delete(self, bot_handler: BotHandler) -> str: 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." @@ -67,9 +76,11 @@ def spam(self, bot_handler: BotHandler) -> str: 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." @@ -77,8 +88,11 @@ def restore(self, bot_handler: BotHandler) -> str: 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." @@ -86,39 +100,43 @@ def comment(self, bot_handler: BotHandler, **kwargs: Any) -> str: return "Comment was sent." def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - command = message['content'] + command = message["content"] - result = re.search(self.CNV_ID_REGEXP, message['subject']) + 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() - if command == 'help': + if command == "help": bot_handler.send_reply(message, self.help(bot_handler)) - elif command == 'archive': + elif command == "archive": bot_handler.send_reply(message, self.archive(bot_handler)) - elif command == 'delete': + elif command == "delete": bot_handler.send_reply(message, self.delete(bot_handler)) - elif command == 'spam': + elif command == "spam": bot_handler.send_reply(message, self.spam(bot_handler)) - elif command == 'open': + elif command == "open": bot_handler.send_reply(message, self.restore(bot_handler)) elif command.startswith(self.COMMENT_PREFIX): kwargs = { - 'author_id': "alt:email:" + message['sender_email'], - 'body': command[len(self.COMMENT_PREFIX):] + "author_id": "alt:email:" + message["sender_email"], + "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 b16c185725..6c0e9ef39c 100644 --- a/zulip_bots/zulip_bots/bots/front/test_front.py +++ b/zulip_bots/zulip_bots/bots/front/test_front.py @@ -2,97 +2,104 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestFrontBot(BotTestCase, DefaultTests): - bot_name = 'front' + bot_name = "front" def make_request_message(self, content: str) -> Dict[str, Any]: message = super().make_request_message(content) - message['subject'] = "cnv_kqatm2" - message['sender_email'] = "leela@planet-express.com" + message["subject"] = "cnv_kqatm2" + message["sender_email"] = "leela@planet-express.com" return message def test_bot_invalid_api_key(self) -> None: - invalid_api_key = '' - with self.mock_config_info({'api_key': invalid_api_key}): + invalid_api_key = "" + with self.mock_config_info({"api_key": invalid_api_key}): with self.assertRaises(KeyError): bot, bot_handler = self._get_handlers() def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info({'api_key': "TEST"}): + with self.mock_config_info({"api_key": "TEST"}): self.verify_reply("", "Unknown command. Use `help` for instructions.") 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") + 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", + ) def test_archive(self) -> None: - with self.mock_config_info({'api_key': "TEST"}): - with self.mock_http_conversation('archive'): - self.verify_reply('archive', "Conversation was archived.") + with self.mock_config_info({"api_key": "TEST"}): + with self.mock_http_conversation("archive"): + self.verify_reply("archive", "Conversation was archived.") def test_archive_error(self) -> None: - self._test_command_error('archive') + self._test_command_error("archive") def test_delete(self) -> None: - with self.mock_config_info({'api_key': "TEST"}): - with self.mock_http_conversation('delete'): - self.verify_reply('delete', "Conversation was deleted.") + with self.mock_config_info({"api_key": "TEST"}): + with self.mock_http_conversation("delete"): + self.verify_reply("delete", "Conversation was deleted.") def test_delete_error(self) -> None: - self._test_command_error('delete') + self._test_command_error("delete") def test_spam(self) -> None: - with self.mock_config_info({'api_key': "TEST"}): - with self.mock_http_conversation('spam'): - self.verify_reply('spam', "Conversation was marked as spam.") + with self.mock_config_info({"api_key": "TEST"}): + with self.mock_http_conversation("spam"): + self.verify_reply("spam", "Conversation was marked as spam.") def test_spam_error(self) -> None: - self._test_command_error('spam') + self._test_command_error("spam") def test_restore(self) -> None: - with self.mock_config_info({'api_key': "TEST"}): - with self.mock_http_conversation('open'): - self.verify_reply('open', "Conversation was restored.") + with self.mock_config_info({"api_key": "TEST"}): + with self.mock_http_conversation("open"): + self.verify_reply("open", "Conversation was restored.") def test_restore_error(self) -> None: - self._test_command_error('open') + self._test_command_error("open") def test_comment(self) -> None: body = "@bender, I thought you were supposed to be cooking for this party." - with self.mock_config_info({'api_key': "TEST"}): - with self.mock_http_conversation('comment'): + with self.mock_config_info({"api_key": "TEST"}): + with self.mock_http_conversation("comment"): self.verify_reply("comment " + body, "Comment was sent.") def test_comment_error(self) -> None: body = "@bender, I thought you were supposed to be cooking for this party." - self._test_command_error('comment', body) + self._test_command_error("comment", body) def _test_command_error(self, command_name: str, command_arg: Optional[str] = None) -> None: bot_command = command_name if command_arg: - bot_command += ' {}'.format(command_arg) - with self.mock_config_info({'api_key': "TEST"}): - with self.mock_http_conversation('{}_error'.format(command_name)): - self.verify_reply(bot_command, 'Something went wrong.') + bot_command += f" {command_arg}" + with self.mock_config_info({"api_key": "TEST"}): + with self.mock_http_conversation(f"{command_name}_error"): + self.verify_reply(bot_command, "Something went wrong.") class TestFrontBotWrongTopic(BotTestCase, DefaultTests): - bot_name = 'front' + bot_name = "front" def make_request_message(self, content: str) -> Dict[str, Any]: message = super().make_request_message(content) - message['subject'] = "kqatm2" + message["subject"] = "kqatm2" return message def test_bot_responds_to_empty_message(self) -> None: pass 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.") + 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.", + ) 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 5c9a0256f5..300fd23d23 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 @@ -1,61 +1,57 @@ -from zulip_bots.game_handler import GameAdapter, BadMoveException -from typing import List, Any +from typing import Any, List + +from zulip_bots.game_handler import BadMoveException, GameAdapter class GameHandlerBotMessageHandler: - tokens = [':blue_circle:', ':red_circle:'] + tokens = [":blue_circle:", ":red_circle:"] def parse_board(self, board: Any) -> str: - return 'foo' + return "foo" def get_player_color(self, turn: int) -> str: return self.tokens[turn] def alert_move_message(self, original_player: str, move_info: str) -> str: - column_number = move_info.replace('move ', '') - return original_player + ' moved in column ' + column_number + column_number = move_info.replace("move ", "") + return original_player + " moved in column " + column_number def game_start_message(self) -> str: - return 'Type `move ` to place a token.\n \ + return "Type `move ` to place a token.\n \ The first player to get 4 in a row wins!\n \ -Good Luck!' +Good Luck!" class MockModel: def __init__(self) -> None: - self.current_board = 'mock board' - - def make_move( - self, - move: str, - player: int, - is_computer: bool = False - ) -> Any: + self.current_board = "mock board" + + 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' + if int(move.replace("move ", "")) < 9: + return "mock board" else: - raise BadMoveException('Invalid Move.') - return 'mock board' + raise BadMoveException("Invalid Move.") + return "mock board" def determine_game_over(self, players: List[str]) -> None: return None class GameHandlerBotHandler(GameAdapter): - ''' + """ DO NOT USE THIS BOT This bot is used to test game_handler.py - ''' + """ def __init__(self) -> None: - game_name = 'foo test game' - bot_name = 'game_handler_bot' - move_help_message = '* To make your move during a game, type\n```move ```' - move_regex = r'move (\d)$' + game_name = "foo test game" + bot_name = "game_handler_bot" + move_help_message = "* To make your move during a game, type\n```move ```" + move_regex = r"move (\d)$" model = MockModel gameMessageHandler = GameHandlerBotMessageHandler - rules = '' + rules = "" super().__init__( game_name, @@ -66,7 +62,7 @@ def __init__(self) -> None: 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 32c4a31f78..a437c6548c 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 @@ -1,22 +1,21 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.game_handler import GameInstance - -from mock import patch - from typing import Any, Dict, List +from unittest.mock import patch + +from zulip_bots.game_handler import GameInstance +from zulip_bots.test_lib import BotTestCase, DefaultTests class TestGameHandlerBot(BotTestCase, DefaultTests): - bot_name = 'game_handler_bot' + bot_name = "game_handler_bot" def make_request_message( self, content: str, - user: str = 'foo@example.com', - user_name: str = 'foo', - type: str = 'private', - stream: str = '', - subject: str = '' + user: str = "foo@example.com", + user_name: str = "foo", + type: str = "private", + stream: str = "", + subject: str = "", ) -> Dict[str, str]: message = dict( sender_email=user, @@ -35,74 +34,78 @@ def verify_response( expected_response: str, response_number: int, bot: Any = None, - user_name: str = 'foo', - stream: str = '', - subject: str = '', - max_messages: int = 20 + user_name: str = "foo", + stream: str = "", + subject: str = "", + max_messages: int = 20, ) -> None: - ''' + """ This function serves a similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be validated, and for mocking of the bot's internal data - ''' + """ if bot is None: bot, bot_handler = self._get_handlers() else: _b, bot_handler = self._get_handlers() - type = 'private' if stream == '' else 'stream' + 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.assertEqual(expected_response, first_response["content"]) self.assertLessEqual(len(responses), max_messages) def add_user_to_cache(self, name: str, bot: Any = None) -> Any: if bot is None: bot, bot_handler = self._get_handlers() message = { - 'sender_email': '{}@example.com'.format(name), - 'sender_full_name': '{}'.format(name) + "sender_email": f"{name}@example.com", + "sender_full_name": f"{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: self.add_user_to_cache(p, bot) - players_emails = [p + '@example.com' for p in players] - game_id = 'abc123' - if id != '': + players_emails = [p + "@example.com" for p in players] + 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() return bot def setup_computer_game(self) -> Any: - 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') - bot.instances.update({'abc123': instance}) + 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" + ) + bot.instances.update({"abc123": instance}) instance.start() return bot def help_message(self) -> str: - return '''** foo test game Bot Help:** + return """** foo test game Bot Help:** *Preface all commands with @**test-bot*** * To start a game in a stream (*recommended*), type `start game` @@ -125,229 +128,321 @@ def help_message(self) -> str: * To see rules of this game, type `rules` * To make your move during a game, type -```move ```''' +```move ```""" def test_help_message(self) -> None: - self.verify_response('help', self.help_message(), 0) - self.verify_response('foo bar baz', self.help_message(), 0) + self.verify_response("help", self.help_message(), 0) + 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): - self.verify_response('quit', 'Error .', 0) + 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) + 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.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) + 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.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 = 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", + ) self.assertEqual(len(bot.instances.keys()), 1) def test_start_game_in_stream(self) -> None: self.verify_response( - 'start game', - '**foo** wants to play **foo test game**. Type @**test-bot** join to play them!', + "start game", + "**foo** wants to play **foo test game**. Type @**test-bot** join to play them!", 0, - stream='test', - subject='test game' + stream="test", + subject="test game", ) def test_start_invite_game_in_stream(self) -> None: - bot = self.add_user_to_cache('baz') + bot = self.add_user_to_cache("baz") self.verify_response( - 'start game with @**baz**', + "start game with @**baz**", 'If you were invited, and you\'re here, type "@**test-bot** accept" to accept the invite!', 2, bot=bot, - stream='test', - subject='game test' + stream="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') - self.add_user_to_cache('foo', bot) + bot = self.add_user_to_cache("baz") + self.add_user_to_cache("foo", bot) bot.invites = { - 'abc': { - 'subject': '###private###', - 'stream': 'games', - 'host': 'foo@example.com', - 'baz@example.com': 'p' + "abc": { + "subject": "###private###", + "stream": "games", + "host": "foo@example.com", + "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 = 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 = 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") 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( + "start game", + "You are already in a game. Type `quit` to leave.", + 0, + bot=bot, + stream="test", + max_messages=1, + ) self.verify_response( - 'accept', '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( - 'decline', '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) + "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.assertIn('foo@example.com', bot.user_cache.keys()) + bot = self.add_user_to_cache("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 = 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') - self.add_user_to_cache('foo', bot) + bot = self.add_user_to_cache("baz") + self.add_user_to_cache("foo", bot) bot.invites = { - 'abc': { - 'stream': 'test', - 'subject': 'test game', - 'host': 'baz@example.com', - 'foo@example.com': 'p' + "abc": { + "stream": "test", + "subject": "test game", + "host": "baz@example.com", + "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) - bot = self.add_user_to_cache('bar') + "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 = 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,147 +450,220 @@ def test_game_already_in_subject(self) -> None: # 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' - } - } - self.assertFalse(bot.is_user_not_player('foo@example.com')) - self.assertFalse(bot.is_user_not_player('baz@example.com')) + 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"}} + 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() - self.assertEqual(bot.get_game_id_by_email('foo@example.com'), 'abc123') + self.assertEqual(bot.get_game_id_by_email("foo@example.com"), "abc123") 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') - leaderboard = '**Most wins**\n\n\ + 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\ **foo** | 1 | 0 | 0 | 1\n\ **baz** | 0 | 0 | 1 | 1\n\ - **test-bot** | 0 | 0 | 0 | 0' - self.verify_response('leaderboard', leaderboard, 0, bot=bot) + **test-bot** | 0 | 0 | 0 | 0" + self.verify_response("leaderboard", leaderboard, 0, bot=bot) 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') - bot.add_user_statistics('foo@example.com', {'foo': 3}) - self.assertEqual(bot.user_cache['foo@example.com']['stats']['foo'], 3) + bot = self.add_user_to_cache("foo") + bot.add_user_statistics("foo@example.com", {"foo": 3}) + self.assertEqual(bot.user_cache["foo@example.com"]["stats"]["foo"], 3) def test_get_players(self) -> None: bot = self.setup_game() - players = bot.get_players('abc123') - self.assertEqual(players, ['foo@example.com', 'baz@example.com']) + players = bot.get_players("abc123") + self.assertEqual(players, ["foo@example.com", "baz@example.com"]) def test_none_function_responses(self) -> None: bot, bot_handler = self._get_handlers() - self.assertEqual(bot.get_players('abc'), []) - self.assertEqual(bot.get_user_by_name('no one'), {}) - self.assertEqual(bot.get_user_by_email('no one'), {}) + self.assertEqual(bot.get_players("abc"), []) + self.assertEqual(bot.get_user_by_name("no one"), {}) + self.assertEqual(bot.get_user_by_email("no one"), {}) def test_get_game_info(self) -> None: - bot = self.add_user_to_cache('foo') - self.add_user_to_cache('baz', bot) + 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', - 'stream': 'test', - 'subject': 'test game' + "abcdefg": { + "host": "foo@example.com", + "baz@example.com": "a", + "stream": "test", + "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\ + 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\ 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' + "foo bar baz": { + "host": "foo@example.com", + "baz@example.com": "a", + "stream": "test", + "subject": "test game", } } - bot.change_game_subject('foo bar baz', 'test2', - 'game2', self.make_request_message('foo')) - self.assertEqual(bot.invites['foo bar baz']['stream'], 'test2') + 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 124de95263..0c0e013e56 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 @@ -1,17 +1,14 @@ import copy +from typing import Any, Dict, List, Tuple + +from zulip_bots.game_handler import BadMoveException, GameAdapter -from typing import List, Any, Tuple, Dict -from zulip_bots.game_handler import GameAdapter, BadMoveException 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: @@ -34,13 +31,13 @@ def get_coordinates(self, board: List[List[int]]) -> Dict[int, Tuple[int, int]]: def determine_game_over(self, players: List[str]) -> str: if self.won(self.current_board): - return 'current turn' - return '' + return "current turn" + return "" 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 @@ -55,82 +52,90 @@ def update_board(self, board): def make_move(self, move: str, player_number: int, computer_move: bool = False) -> Any: board = self.current_board move = move.strip() - move = move.split(' ') + move = move.split(" ") - if '' in move: - raise BadMoveException('You should enter space separated digits.') + if "" in move: + raise BadMoveException("You should enter space separated digits.") moves = len(move) for m in range(1, moves): tile = int(move[m]) coordinates = self.get_coordinates(board) if tile not in coordinates: - raise BadMoveException('You can only move tiles which exist in the board.') + 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 = { - '0': ':grey_question:', - '1': ':one:', - '2': ':two:', - '3': ':three:', - '4': ':four:', - '5': ':five:', - '6': ':six:', - '7': ':seven:', - '8': ':eight:', + "0": ":grey_question:", + "1": ":one:", + "2": ":two:", + "3": ":three:", + "4": ":four:", + "5": ":five:", + "6": ":six:", + "7": ":seven:", + "8": ":eight:", } def parse_board(self, board: Any) -> str: # Header for the top of the board - board_str = '' + board_str = "" for row in range(3): - board_str += '\n\n' + board_str += "\n\n" for column in range(3): board_str += self.tiles[str(board[row][column])] return board_str def alert_move_message(self, original_player: str, move_info: str) -> str: - tile = move_info.replace('move ', '') - return original_player + ' moved ' + tile + tile = move_info.replace("move ", "") + 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): - ''' + """ Bot that uses the Game Adapter class to allow users to play Game of Fifteen - ''' + """ 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_regex = r'move [\d{1}\s]+$' + game_name = "Game of Fifteen" + bot_name = "Game of Fifteen" + move_help_message = ( + "* To make your move during a game, type\n```move ...```" + ) + move_regex = r"move [\d{1}\s]+$" model = GameOfFifteenModel gameMessageHandler = GameOfFifteenMessageHandler - rules = '''Arrange the board’s tiles from smallest to largest, left to right, + rules = """Arrange the board’s tiles from smallest to largest, left to right, top to bottom, and tiles adjacent to :grey_question: can only be moved. - Final configuration will have :grey_question: in top left.''' + Final configuration will have :grey_question: in top left.""" super().__init__( game_name, @@ -144,4 +149,5 @@ def __init__(self) -> None: 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 0fc12768ac..b3d676761a 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 @@ -1,34 +1,33 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests +from typing import Dict, List, Tuple from zulip_bots.bots.game_of_fifteen.game_of_fifteen import GameOfFifteenModel from zulip_bots.game_handler import BadMoveException -from typing import Dict, List, Tuple +from zulip_bots.test_lib import BotTestCase, DefaultTests class TestGameOfFifteenBot(BotTestCase, DefaultTests): - bot_name = 'game_of_fifteen' + 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 for multiple responses to be validated, and for mocking of the bot's internal data - ''' + """ bot, bot_handler = self._get_handlers() message = self.make_request_message(request, user) @@ -36,17 +35,13 @@ def verify_response(self, request: str, expected_response: str, response_number: 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.assertEqual(expected_response, first_response["content"]) def help_message(self) -> str: - return '''** Game of Fifteen Bot Help:** + return """** Game of Fifteen Bot Help:** *Preface all commands with @**test-bot*** * To start a game in a stream, type `start game` @@ -55,31 +50,27 @@ def help_message(self) -> str: * To see rules of this game, type `rules` * To make your move during a game, type -```move ...```''' +```move ...```""" def test_static_responses(self) -> None: - self.verify_response('help', self.help_message(), 0) + self.verify_response("help", self.help_message(), 0) 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:' + 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 ...`") - - winning_board = [[0, 1, 2], - [3, 4, 5], - [6, 7, 8]] + 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]] 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 @@ def confirmMove( 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,77 +104,56 @@ def confirm_coordinates(board: List[List[int]], result: Dict[int, Tuple[int, int 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 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 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 7', 0, sample_board, - [[0, 6, 8], - [7, 3, 1], - [2, 4, 5]]) + 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 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("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]]) # 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') - confirmGameOver(sample_board, '') + confirmGameOver(winning_board, "current turn") + confirmGameOver(sample_board, "") def test_invalid_moves(self) -> None: model = GameOfFifteenModel() - move1 = 'move 2' - move2 = 'move 5' - move3 = 'move 23' - move4 = 'move 0' - move5 = 'move 1 2' - initial_board = [[8, 7, 6], - [5, 4, 3], - [2, 1, 0]] + move1 = "move 2" + move2 = "move 5" + move3 = "move 23" + move4 = "move 0" + move5 = "move 1 2" + 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 2c985c2c79..a0a5a421ef 100644 --- a/zulip_bots/zulip_bots/bots/giphy/giphy.py +++ b/zulip_bots/zulip_bots/bots/giphy/giphy.py @@ -1,13 +1,14 @@ +import logging from typing import Dict, Union -from zulip_bots.lib import BotHandler + import requests -import logging -from requests.exceptions import HTTPError, ConnectionError +from requests.exceptions import ConnectionError, HTTPError from zulip_bots.custom_exceptions import ConfigValidationError +from zulip_bots.lib import BotHandler -GIPHY_TRANSLATE_API = 'http://api.giphy.com/v1/gifs/translate' -GIPHY_RANDOM_API = 'http://api.giphy.com/v1/gifs/random' +GIPHY_TRANSLATE_API = "http://api.giphy.com/v1/gifs/translate" +GIPHY_RANDOM_API = "http://api.giphy.com/v1/gifs/random" class GiphyHandler: @@ -18,17 +19,17 @@ 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 ''' + return """ This plugin allows users to post GIFs provided by Giphy. Users should preface keywords with the Giphy-bot @mention. The bot responds also to private messages. - ''' + """ @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() @@ -37,19 +38,17 @@ def validate_config(config_info: Dict[str, str]) -> None: 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') + 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) @@ -61,9 +60,9 @@ def get_url_gif_giphy(keyword: str, api_key: str) -> Union[int, str]: # Return a URL for a Giphy GIF based on keywords given. # In case of error, e.g. failure to fetch a GIF URL, it will # return a number. - query = {'api_key': api_key} + query = {"api_key": api_key} if len(keyword) > 0: - query['s'] = keyword + query["s"] = keyword url = GIPHY_TRANSLATE_API else: url = GIPHY_RANDOM_API @@ -71,32 +70,37 @@ def get_url_gif_giphy(keyword: str, api_key: str) -> Union[int, str]: try: data = requests.get(url, params=query) except requests.exceptions.ConnectionError: # Usually triggered by bad connection. - logging.exception('Bad connection') + logging.exception("Bad connection") raise data.raise_for_status() try: - gif_url = data.json()['data']['images']['original']['url'] + gif_url = data.json()["data"]["images"]["original"]["url"] except (TypeError, KeyError): # Usually triggered by no result in Giphy. raise GiphyNoResultException() 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'] + keyword = message["content"] try: - gif_url = get_url_gif_giphy(keyword, config_info['key']) + 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 3349150ccd..fb21d40eb3 100755 --- a/zulip_bots/zulip_bots/bots/giphy/test_giphy.py +++ b/zulip_bots/zulip_bots/bots/giphy/test_giphy.py @@ -1,61 +1,68 @@ from unittest.mock import patch + from requests.exceptions import ConnectionError -from zulip_bots.test_lib import StubBotHandler, BotTestCase, DefaultTests, get_bot_message_handler +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_bot_message_handler + class TestGiphyBot(BotTestCase, DefaultTests): bot_name = "giphy" # 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'): - self.verify_reply('', bot_response) + 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'): - self.verify_reply('Hello', bot_response) + 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', + "world without zulip", 'Sorry, I don\'t have a GIF for "world without zulip"! :astonished:', ) def test_invalid_config(self) -> None: 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") + with self.mock_http_conversation("test_403"): + 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() - with patch('requests.get', side_effect=ConnectionError()): - self.validate_invalid_config({'key': '12345678'}, str(error)) + with patch("requests.get", side_effect=ConnectionError()): + self.validate_invalid_config({"key": "12345678"}, str(error)) def test_valid_config(self) -> None: get_bot_message_handler(self.bot_name) StubBotHandler() - with self.mock_http_conversation('test_normal'): - self.validate_valid_config({'key': '12345678'}) + with self.mock_http_conversation("test_normal"): + 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:') + "world without chocolate", + "Uh oh, sorry :slightly_frowning_face:, I " + "cannot process your request right now. But, " + "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 ba46535fbd..726e50be0d 100644 --- a/zulip_bots/zulip_bots/bots/github_detail/github_detail.py +++ b/zulip_bots/zulip_bots/bots/github_detail/github_detail.py @@ -1,54 +1,64 @@ -import re import logging +import re +from typing import Any, Dict, Tuple, Union import requests -from typing import Dict, Any, Tuple, Union from zulip_bots.lib import BotHandler + class GithubHandler: - ''' + """ This bot provides details on github issues and pull requests when they're referenced in the chat. - ''' + """ - GITHUB_ISSUE_URL_TEMPLATE = 'https://api.github.com/repos/{owner}/{repo}/issues/{id}' + GITHUB_ISSUE_URL_TEMPLATE = "https://api.github.com/repos/{owner}/{repo}/issues/{id}" HANDLE_MESSAGE_REGEX = re.compile(r"(?:([\w-]+)\/)?([\w-]+)?#(\d+)") def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('github_detail', optional=True) + self.config_info = bot_handler.get_config_info("github_detail", optional=True) self.owner = self.config_info.get("owner", False) 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'] - title = details['title'] - link = details['html_url'] - author = details['user']['login'] - owner = details['owner'] - repo = details['repo'] - - 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)) - return ''.join(message_string) - - def get_details_from_github(self, owner: str, repo: str, number: str) -> Union[None, Dict[str, Union[str, int, bool]]]: + number = details["number"] + title = details["title"] + link = details["html_url"] + author = details["user"]["login"] + owner = details["owner"] + repo = details["repo"] + + description = details["body"] + status = details["state"].title() + + message_string = ( + f"**[{owner}/{repo}#{number}]", + f"({link}) - {title}**\n", + "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]]]: # 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 @@ -67,17 +77,16 @@ def get_owner_and_repo(self, issue_pr: Any) -> Tuple[str, str]: def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: # Send help message - if message['content'] == 'help': + if message["content"] == "help": bot_handler.send_reply(message, self.usage()) 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 - bot_message = 'Please ask for <=5 links in any one request' + bot_message = "Please ask for <=5 links in any one request" bot_handler.send_reply(message, bot_message) return @@ -86,17 +95,21 @@ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> No if owner and repo: details = self.get_details_from_github(owner, repo, issue_pr.group(3)) if details is not None: - details['owner'] = owner - details['repo'] = repo + details["owner"] = owner + 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: bot_messages.append("Failed to find any issue or PR.") - bot_message = '\n'.join(bot_messages) + 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 dc9eaa11f8..b555d555ef 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 @@ -1,14 +1,10 @@ -from zulip_bots.test_lib import ( - StubBotHandler, - BotTestCase, - DefaultTests, - get_bot_message_handler, -) +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_bot_message_handler + class TestGithubDetailBot(BotTestCase, DefaultTests): bot_name = "github_detail" - mock_config = {'owner': 'zulip', 'repo': 'zulip'} - empty_config = {'owner': '', 'repo': ''} + mock_config = {"owner": "zulip", "repo": "zulip"} + empty_config = {"owner": "", "repo": ""} # Overrides default test_bot_usage(). def test_bot_usage(self) -> None: @@ -18,124 +14,136 @@ def test_bot_usage(self) -> None: with self.mock_config_info(self.mock_config): bot.initialize(bot_handler) - self.assertIn('displays details on github issues', bot.usage()) + self.assertIn("displays details on github issues", bot.usage()) # Override default function in BotTestCase def test_bot_responds_to_empty_message(self) -> None: with self.mock_config_info(self.mock_config): - self.verify_reply('', 'Failed to find any issue or PR.') + self.verify_reply("", "Failed to find any issue or PR.") 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```' - - with self.mock_http_conversation('test_issue'): + 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```" + ) + + with self.mock_http_conversation("test_issue"): with self.mock_config_info(self.mock_config): self.verify_reply(request, bot_response) 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```' - with self.mock_http_conversation('test_pull'): + 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```" + ) + with self.mock_http_conversation("test_pull"): with self.mock_config_info(self.mock_config): self.verify_reply(request, bot_response) def test_404(self) -> None: - request = 'zulip/zulip#0' - bot_response = 'Failed to find issue/pr: zulip/zulip#0' - with self.mock_http_conversation('test_404'): + request = "zulip/zulip#0" + bot_response = "Failed to find issue/pr: zulip/zulip#0" + with self.mock_http_conversation("test_404"): with self.mock_config_info(self.mock_config): self.verify_reply(request, bot_response) def test_exception(self) -> None: - request = 'zulip/zulip#0' - bot_response = 'Failed to find issue/pr: zulip/zulip#0' + request = "zulip/zulip#0" + bot_response = "Failed to find issue/pr: zulip/zulip#0" with self.mock_request_exception(): with self.mock_config_info(self.mock_config): self.verify_reply(request, bot_response) def test_random_text(self) -> None: - request = 'some random text' - bot_response = 'Failed to find any issue or PR.' + request = "some random text" + bot_response = "Failed to find any issue or PR." with self.mock_config_info(self.mock_config): self.verify_reply(request, bot_response) 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.' + 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." + ) 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' - bot_response = 'Please ask for <=5 links in any one request' + 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): self.verify_reply(request, bot_response) def test_owner_and_repo_not_specified(self) -> None: - request = '/#1' - bot_response = 'Failed to detect owner and repository name.' + request = "/#1" + bot_response = "Failed to detect owner and repository name." with self.mock_config_info(self.empty_config): self.verify_reply(request, bot_response) 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```' - with self.mock_http_conversation('test_pull'): + 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```" + ) + 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```' - with self.mock_http_conversation('test_pull'): + 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```" + ) + 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 9a521ea180..dc981d5e98 100644 --- a/zulip_bots/zulip_bots/bots/google_search/google_search.py +++ b/zulip_bots/zulip_bots/bots/google_search/google_search.py @@ -1,42 +1,42 @@ # See readme.md for instructions on running this code. import logging +from typing import Dict, List import requests - from bs4 import BeautifulSoup -from typing import Dict, List from zulip_bots.lib import BotHandler + def google_search(keywords: str) -> List[Dict[str, str]]: - query = {'q': keywords} + query = {"q": keywords} # Gets the page - page = requests.get('http://www.google.com/search', params=query) + page = requests.get("http://www.google.com/search", params=query) # Parses the page into BeautifulSoup soup = BeautifulSoup(page.text, "lxml") # Gets all search URLs - anchors = soup.find(id='search').findAll('a') + anchors = soup.find(id="search").findAll("a") results = [] for a in anchors: try: # Tries to get the href property of the URL - link = a['href'] + link = a["href"] except KeyError: continue # Link must start with '/url?', as these are the search result links - if not link.startswith('/url?'): + if not link.startswith("/url?"): continue # Makes sure a hidden 'cached' result isn't displayed - if a.text.strip() == 'Cached' and 'webcache.googleusercontent.com' in a['href']: + 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": f"https://www.google.com{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 \ @@ -49,42 +49,44 @@ def get_google_result(search_keywords: str) -> str: search_keywords = search_keywords.strip() - if search_keywords == 'help': + if search_keywords == "help": return help_message - elif search_keywords == '' or search_keywords is None: + elif search_keywords == "" or search_keywords is None: return help_message 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']) + return "Found Result: [{}]({})".format(results[0]["name"], results[0]["url"]) except Exception as e: logging.exception(str(e)) - return 'Error: Search failed. {}.'.format(e) + return f"Error: Search failed. {e}." + class GoogleSearchHandler: - ''' + """ This plugin allows users to enter a search term in Zulip and get the top URL sent back to the context (stream or private) in which it was called. It looks for messages starting with @mentioned-bot. - ''' + """ def usage(self) -> str: - return ''' + return """ This plugin will allow users to search for a given search term on Google from Zulip. Use '@mentioned-bot help' to get more information on the bot usage. Users should preface messages with @mentioned-bot. - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - original_content = message['content'] + original_content = message["content"] 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 fbffb5e86d..c6f0b4e0b8 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 @@ -1,16 +1,17 @@ +from unittest.mock import patch + from zulip_bots.test_lib import BotTestCase, DefaultTests -from unittest.mock import patch class TestGoogleSearchBot(BotTestCase, DefaultTests): - bot_name = 'google_search' + bot_name = "google_search" # Simple query def test_normal(self) -> None: - with self.mock_http_conversation('test_normal'): + 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)' + "zulip", + "Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)", ) def test_bot_help(self) -> None: @@ -22,25 +23,26 @@ def test_bot_help(self) -> None: An example message that could be sent is:\ '@mentioned-bot zulip' or \ '@mentioned-bot how to create a chatbot'." - self.verify_reply('', help_message) - self.verify_reply('help', help_message) + self.verify_reply("", help_message) + self.verify_reply("help", help_message) def test_bot_no_results(self) -> None: - with self.mock_http_conversation('test_no_result'): - self.verify_reply('no res', 'Found no results.') + with self.mock_http_conversation("test_no_result"): + 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: - with self.mock_http_conversation('test_ignore_links'): + with self.mock_http_conversation("test_ignore_links"): # The bot should ignore all links, apart from the zulip link at the end (googlesearch.py lines 23-38) # Then it should send the zulip link # See test_ignore_links.json self.verify_reply( - 'zulip', - 'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)' + "zulip", + "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 7db5d30073..579bc0d4b9 100644 --- a/zulip_bots/zulip_bots/bots/google_translate/google_translate.py +++ b/zulip_bots/zulip_bots/bots/google_translate/google_translate.py @@ -3,81 +3,91 @@ import requests + class GoogleTranslateHandler: - ''' + """ This bot will translate any messages sent to it using google translate. 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 ''' + return """ This plugin allows users translate messages Users should @-mention the bot with the format @-mention "" - ''' + """ def initialize(self, bot_handler): - self.config_info = bot_handler.get_config_info('googletranslate') + self.config_info = bot_handler.get_config_info("googletranslate") # Retrieving the supported languages also serves as a check whether # the bot is properly connected to the Google Translate API. try: - self.supported_languages = get_supported_languages(self.config_info['key']) + self.supported_languages = get_supported_languages(self.config_info["key"]) except TranslateError as e: 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 = ''' +api_url = "https://translation.googleapis.com/language/translate/v2" + +help_text = """ Google translate bot Please format your message like: `@-mention "" ` Visit [here](https://cloud.google.com/translate/docs/languages) for all languages -''' +""" + +language_not_found_text = "{} language not found. Visit [here](https://cloud.google.com/translate/docs/languages) for all languages" -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) + parameters = {"key": key, "target": "en"} + 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']) + 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 != '': - parameters.update({'source': src}) + parameters = {"q": text_to_translate, "target": dest, "key": key} + if src != "": + parameters.update({"source": src}) response = requests.post(api_url, params=parameters) if response.status_code == requests.codes.ok: - return response.json()['data']['translations'][0]['translatedText'] - raise TranslateError(response.json()['error']['message']) + 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(): - return '' + return "" 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('"'): + if message_content == "help" or message_content is None or not message_content.startswith('"'): return help_text split_text = message_content.rsplit('" ', 1) if len(split_text) == 1: return help_text - split_text += split_text.pop(1).split(' ') + split_text += split_text.pop(1).split(" ") if len(split_text) == 2: # There is no source language split_text.append("") @@ -86,20 +96,23 @@ def get_translate_bot_response(message_content, config_file, author, all_languag (text_to_translate, target_language, source_language) = split_text text_to_translate = text_to_translate[1:] target_language = get_code_for_language(target_language, all_languages) - if target_language == '': + if target_language == "": return language_not_found_text.format("Target") - if source_language != '': + if source_language != "": source_language = get_code_for_language(source_language, all_languages) - if source_language == '': + 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) + return f"Could not connect to Google Translate. {conn_err}." except TranslateError as tr_err: - return "Translate Error. {}.".format(tr_err) + return f"Translate Error. {tr_err}." except Exception as err: - return "Error. {}.".format(err) - return "{} (from {})".format(translated_text, author) + return f"Error. {err}." + return f"{translated_text} (from {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 bad4d83f26..f40ef6f57f 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 @@ -1,21 +1,24 @@ from unittest.mock import patch + from requests.exceptions import ConnectionError from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler -help_text = ''' +help_text = """ Google translate bot Please format your message like: `@-mention "" ` 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) @@ -23,59 +26,71 @@ def _test(self, message, response, http_config_fixture, http_fixture=None): self.verify_reply(message, response) def test_normal(self): - self._test('"hello" de', 'Hallo (from Foo Test User)', 'test_languages', 'test_normal') + 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): - self._test('', help_text, 'test_languages') + self._test("", help_text, "test_languages") def test_help_command(self): - self._test('help', help_text, 'test_languages') + self._test("help", help_text, "test_languages") def test_help_too_many_args(self): - self._test('"hello" de english foo bar', help_text, 'test_languages') + self._test('"hello" de english foo bar', help_text, "test_languages") def test_help_no_langs(self): - self._test('"hello"', help_text, 'test_languages') + 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): - self._test('"hello" de', 'Error. .', 'test_languages') + 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): with self.assertRaises(StubBotHandler.BotQuitException): - self._test(None, None, 'test_invalid_api_key') + self._test(None, None, "test_invalid_api_key") def test_api_access_not_configured(self): with self.assertRaises(StubBotHandler.BotQuitException): - self._test(None, None, 'test_api_access_not_configured') + 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 0839266218..79e9437fb2 100644 --- a/zulip_bots/zulip_bots/bots/helloworld/helloworld.py +++ b/zulip_bots/zulip_bots/bots/helloworld/helloworld.py @@ -1,24 +1,27 @@ # See readme.md for instructions on running this code. from typing import Any, Dict + from zulip_bots.lib import BotHandler + class HelloWorldHandler: def usage(self) -> str: - return ''' + return """ This is a boilerplate bot that responds to a user query with "beep boop", which is robot for "Hello World". This bot can be used as a template for other, more sophisticated, bots. - ''' + """ def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: - content = 'beep boop' # type: str + 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/helloworld/test_helloworld.py b/zulip_bots/zulip_bots/bots/helloworld/test_helloworld.py index e65268ff98..c3cd804808 100755 --- a/zulip_bots/zulip_bots/bots/helloworld/test_helloworld.py +++ b/zulip_bots/zulip_bots/bots/helloworld/test_helloworld.py @@ -1,13 +1,14 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestHelpBot(BotTestCase, DefaultTests): bot_name = "helloworld" # type: str def test_bot(self) -> None: dialog = [ - ('', 'beep boop'), - ('help', 'beep boop'), - ('foo', 'beep boop'), + ("", "beep boop"), + ("help", "beep boop"), + ("foo", "beep boop"), ] self.verify_dialog(dialog) diff --git a/zulip_bots/zulip_bots/bots/help/help.py b/zulip_bots/zulip_bots/bots/help/help.py index cd75cfc8a1..1d95112f9f 100644 --- a/zulip_bots/zulip_bots/bots/help/help.py +++ b/zulip_bots/zulip_bots/bots/help/help.py @@ -1,20 +1,23 @@ # See readme.md for instructions on running this code. from typing import Dict + from zulip_bots.lib import BotHandler + class HelpHandler: def usage(self) -> str: - return ''' + return """ This plugin will give info about Zulip to any user that types a message saying "help". This is example code; ideally, you would flesh this out for more useful help pertaining to your Zulip instance. - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: 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 d4bcce6f85..22956ef647 100755 --- a/zulip_bots/zulip_bots/bots/help/test_help.py +++ b/zulip_bots/zulip_bots/bots/help/test_help.py @@ -1,5 +1,6 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestHelpBot(BotTestCase, DefaultTests): bot_name = "help" @@ -7,9 +8,6 @@ def test_bot(self) -> None: 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 13f58298ba..a84346f34e 100644 --- a/zulip_bots/zulip_bots/bots/idonethis/idonethis.py +++ b/zulip_bots/zulip_bots/bots/idonethis/idonethis.py @@ -1,8 +1,9 @@ -import requests import logging import re +from typing import Any, Dict, List, Optional + +import requests -from typing import Any, Dict, Optional, List from zulip_bots.lib import BotHandler API_BASE_URL = "https://beta.idonethis.com/api/v2" @@ -10,50 +11,66 @@ 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: - headers = {'Authorization': 'Token ' + api_key} + +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) elif method == "POST": 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": - logging.error('Error authenticating, please check key ' + str(r.url)) + 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()) + 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)) + return make_API_request(f"/teams/{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)) + return make_API_request(f"/teams/{hash_id}/members") + def api_list_entries(team_id: Optional[str] = None) -> List[Dict[str, Any]]: if team_id: @@ -61,46 +78,52 @@ def api_list_entries(team_id: Optional[str] = None) -> List[Dict[str, Any]]: 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()] + 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'] + 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: data = api_list_entries(get_team_hash(team_name)) - response = "Entries for {}:".format(team_name) + response = f"Entries for {team_name}:" else: data = api_list_entries() 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=([^"]*)"') @@ -120,34 +143,43 @@ 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']) + 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 - self.config_info = bot_handler.get_config_info('idonethis') - if 'api_key' in self.config_info: - api_key = self.config_info['api_key'] + self.config_info = bot_handler.get_config_info("idonethis") + if "api_key" in self.config_info: + 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'] + 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") @@ -159,7 +191,8 @@ def usage(self) -> str: 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. @@ -178,13 +211,15 @@ def usage(self) -> str: 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)) def get_response(self, message: Dict[str, Any]) -> str: - message_content = message['content'].strip().split() + message_content = message["content"].strip().split() reply = "" try: command = " ".join(message_content[:2]) @@ -194,7 +229,9 @@ def get_response(self, message: Dict[str, Any]) -> str: 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"]: @@ -204,15 +241,21 @@ def get_response(self, message: Dict[str, Any]) -> str: 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 847148f10e..b3399b3c35 100644 --- a/zulip_bots/zulip_bots/bots/idonethis/test_idonethis.py +++ b/zulip_bots/zulip_bots/bots/idonethis/test_idonethis.py @@ -2,90 +2,122 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests + 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') - get_team_hashFunction.assert_called_with('testing team 1') + 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 8ec1a974b4..f4f8919695 100644 --- a/zulip_bots/zulip_bots/bots/incident/incident.py +++ b/zulip_bots/zulip_bots/bots/incident/incident.py @@ -1,53 +1,57 @@ import json import re from typing import Any, Dict, Tuple + from zulip_bots.lib import BotHandler -QUESTION = 'How should we handle this?' +QUESTION = "How should we handle this?" ANSWERS = { - '1': 'known issue', - '2': 'ignore', - '3': 'in process', - '4': 'escalate', + "1": "known issue", + "2": "ignore", + "3": "in process", + "4": "escalate", } + class InvalidAnswerException(Exception): pass + class IncidentHandler: def usage(self) -> str: - return ''' + return """ This plugin lets folks reports incidents and triage them. It is intended to be sample code. In the real world you'd modify this code to talk to some kind of issue tracking system. But the glue code here should be pretty portable. - ''' + """ def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: - query = message['content'] - if query.startswith('new '): + query = message["content"] + if query.startswith("new "): start_new_incident(query, message, bot_handler) - elif query.startswith('answer '): + elif query.startswith("answer "): try: (ticket_id, answer) = parse_answer(query) except InvalidAnswerException: - bot_response = 'Invalid answer format' + bot_response = "Invalid answer format" bot_handler.send_reply(message, bot_response) return - bot_response = 'Incident %s\n status = %s' % (ticket_id, answer) + bot_response = f"Incident {ticket_id}\n status = {answer}" bot_handler.send_reply(message, bot_response) else: 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) @@ -55,8 +59,9 @@ 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) + m = re.match(r"answer\s+(TICKET....)\s+(.)", query) if not m: raise InvalidAnswerException() @@ -68,42 +73,44 @@ def parse_answer(query: str) -> Tuple[str, str]: # of systems that specialize in incident management.) answer = m.group(2).upper() - if answer not in '1234': + if answer not in "1234": raise InvalidAnswerException() return (ticket_id, ANSWERS[answer]) + def generate_ticket_id(storage: Any) -> str: try: - incident_num = storage.get('ticket_id') + incident_num = storage.get("ticket_id") except (KeyError): incident_num = 0 incident_num += 1 incident_num = incident_num % (1000) - storage.put('ticket_id', incident_num) - ticket_id = 'TICKET%04d' % (incident_num,) + storage.put("ticket_id", incident_num) + 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' + widget_type = "zform" - heading = ticket_id + ': ' + incident + heading = ticket_id + ": " + incident def get_choice(code: str) -> Dict[str, str]: answer = ANSWERS[code] - reply = 'answer ' + ticket_id + ' ' + code + reply = "answer " + ticket_id + " " + code return dict( - type='multiple_choice', + type="multiple_choice", short_name=code, long_name=answer, reply=reply, ) - choices = [get_choice(code) for code in '1234'] + choices = [get_choice(code) for code in "1234"] extra_data = dict( - type='choices', + type="choices", heading=heading, choices=choices, ) @@ -115,22 +122,23 @@ def get_choice(code: str) -> Dict[str, 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( + 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) + for code in "1234" + ) + how_to_respond = f"""**reply**: answer {ticket_id} """ - content = ''' + content = """ Incident: {incident} Q: {question} {answer_list} -{how_to_respond}'''.format( +{how_to_respond}""".format( question=QUESTION, answer_list=answer_list, how_to_respond=how_to_respond, @@ -138,4 +146,5 @@ def format_incident_for_markdown(ticket_id: str, incident: Dict[str, Any]) -> st ) 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 2346316a81..ea42a08baa 100644 --- a/zulip_bots/zulip_bots/bots/incrementor/incrementor.py +++ b/zulip_bots/zulip_bots/bots/incrementor/incrementor.py @@ -1,51 +1,52 @@ # See readme.md for instructions on running this code. from typing import Dict + from zulip_bots.lib import BotHandler, use_storage + class IncrementorHandler: META = { - 'name': 'Incrementor', - 'description': 'Example bot to test the update_message() function.', + "name": "Incrementor", + "description": "Example bot to test the update_message() function.", } def usage(self) -> str: - return ''' + return """ This is a boilerplate bot that makes use of the update_message function. For the first @-mention, it initially replies with one message containing a `1`. Every time the bot is @-mentioned, this number will be incremented in the same message. - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: storage = bot_handler.storage - if not storage.contains('number') or not storage.contains('message_id'): - storage.put('number', 0) - storage.put('message_id', None) + if not storage.contains("number") or not storage.contains("message_id"): + storage.put("number", 0) + storage.put("message_id", None) def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - with use_storage(bot_handler.storage, ['number']) as storage: + with use_storage(bot_handler.storage, ["number"]) as storage: num = storage.get("number") # num should already be an int, but we do `int()` to force an # explicit type check num = int(num) + 1 - 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) - )) + 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)) + ) # When there isn't an error while updating the message, we won't # attempt to send the it again. - if result is None or result.get('result') != 'error': + if result is None or result.get("result") != "error": return message_info = bot_handler.send_reply(message, str(num)) if message_info is not None: - storage.put('message_id', message_info['id']) + storage.put("message_id", message_info["id"]) handler_class = IncrementorHandler diff --git a/zulip_bots/zulip_bots/bots/incrementor/test_incrementor.py b/zulip_bots/zulip_bots/bots/incrementor/test_incrementor.py index aa5f637adf..f1100a04b0 100644 --- a/zulip_bots/zulip_bots/bots/incrementor/test_incrementor.py +++ b/zulip_bots/zulip_bots/bots/incrementor/test_incrementor.py @@ -1,11 +1,7 @@ from unittest.mock import patch -from zulip_bots.test_lib import ( - get_bot_message_handler, - StubBotHandler, - DefaultTests, - BotTestCase, -) +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_bot_message_handler + class TestIncrementorBot(BotTestCase, DefaultTests): bot_name = "incrementor" @@ -14,42 +10,36 @@ def test_bot(self) -> None: bot = get_bot_message_handler(self.bot_name) bot_handler = StubBotHandler() - message = dict(type='stream') + message = dict(type="stream") bot.initialize(bot_handler) bot.handle_message(message, bot_handler) - with patch('zulip_bots.simple_lib.MockMessageServer.update') as m: + with patch("zulip_bots.simple_lib.MockMessageServer.update") as m: bot.handle_message(message, bot_handler) 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 - ] - self.assertEqual(content_updates, ['2', '3', '4']) + 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: bot = get_bot_message_handler(self.bot_name) bot_handler = StubBotHandler() - message = dict(type='stream') + message = dict(type="stream") bot.initialize(bot_handler) bot.handle_message(message, bot_handler) - error_msg = dict(msg='The time limit for editing this message has passed', result='error') - with patch('zulip_bots.test_lib.StubBotHandler.update_message', return_value=error_msg): - with patch('zulip_bots.simple_lib.MockMessageServer.send') as m: + error_msg = dict(msg="The time limit for editing this message has passed", result="error") + with patch("zulip_bots.test_lib.StubBotHandler.update_message", return_value=error_msg): + with patch("zulip_bots.simple_lib.MockMessageServer.send") as m: bot.handle_message(message, bot_handler) bot.handle_message(message, bot_handler) # 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 - ] - self.assertEqual(content_updates, ['2', '3']) + 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 b59ad99065..7c3c81cf36 100644 --- a/zulip_bots/zulip_bots/bots/jira/jira.py +++ b/zulip_bots/zulip_bots/bots/jira/jira.py @@ -1,7 +1,9 @@ import base64 import re -import requests from typing import Any, Dict, Optional + +import requests + from zulip_bots.lib import BotHandler GET_REGEX = re.compile('get "(?P.+)"$') @@ -14,7 +16,7 @@ '( with priority "(?P.+?)")?' '( labeled "(?P.+?)")?' '( due "(?P.+?)")?' - '$' + "$" ) EDIT_REGEX = re.compile( 'edit issue "(?P.+?)"' @@ -26,13 +28,13 @@ '( to use priority "(?P.+?)")?' '( by labeling "(?P.+?)")?' '( by making due "(?P.+?)")?' - '$' + "$" ) SEARCH_REGEX = re.compile('search "(?P.+)"$') JQL_REGEX = re.compile('jql "(?P.+)"$') -HELP_REGEX = re.compile('help$') +HELP_REGEX = re.compile("help$") -HELP_RESPONSE = ''' +HELP_RESPONSE = """ **get** `get` takes in an issue key and sends back information about that issue. For example, @@ -141,67 +143,71 @@ Jira Bot: > Issue *BOTS-16* was edited! https://example.atlassian.net/browse/BOTS-16 -''' +""" + class JiraHandler: def usage(self) -> str: - return ''' + return """ Jira Bot uses the Jira REST API to interact with Jira. In order to use Jira Bot, `jira.conf` must be set up. See `doc.md` for more details. - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: - config = bot_handler.get_config_info('jira') + config = bot_handler.get_config_info("jira") - username = config.get('username') - password = config.get('password') - domain = config.get('domain') + username = config.get("username") + password = config.get("password") + domain = config.get("domain") if not username: - raise KeyError('No `username` was specified') + raise KeyError("No `username` was specified") if not password: - raise KeyError('No `password` was specified') + raise KeyError("No `password` was specified") if not domain: - raise KeyError('No `domain` was specified') + raise KeyError("No `domain` was specified") self.auth = make_jira_auth(username, password) # Allow users to override the HTTP scheme - if re.match(r'^https?://', domain, re.IGNORECASE): + if re.match(r"^https?://", domain, re.IGNORECASE): self.domain_with_protocol = domain else: - self.domain_with_protocol = 'https://' + domain + self.domain_with_protocol = "https://" + domain # Use the front facing URL in output - self.display_url = config.get('display_url') + self.display_url = config.get("display_url") if not self.display_url: self.display_url = self.domain_with_protocol def jql_search(self, jql_query: str) -> str: - UNKNOWN_VAL = '*unknown*' + UNKNOWN_VAL = "*unknown*" jira_response = requests.get( - self.domain_with_protocol + '/rest/api/2/search?jql={}&fields=key,summary,status'.format(jql_query), - headers={'Authorization': self.auth}, + self.domain_with_protocol + + f"/rest/api/2/search?jql={jql_query}&fields=key,summary,status", + headers={"Authorization": self.auth}, ).json() - url = self.display_url + '/browse/' - errors = jira_response.get('errorMessages', []) - results = jira_response.get('total', 0) + url = self.display_url + "/browse/" + errors = jira_response.get("errorMessages", []) + results = jira_response.get("total", 0) if errors: - response = 'Oh no! Jira raised an error:\n > ' + ', '.join(errors) + response = "Oh no! Jira raised an error:\n > " + ", ".join(errors) else: - response = '*Found {} results*\n\n'.format(results) - for issue in jira_response.get('issues', []): - 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 = f"*Found {results} results*\n\n" + for issue in jira_response.get("issues", []): + 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 + ) return response def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - content = message.get('content') - response = '' + content = message.get("content") + response = "" get_match = GET_REGEX.match(content) create_match = CREATE_REGEX.match(content) @@ -211,119 +217,140 @@ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> No help_match = HELP_REGEX.match(content) if get_match: - UNKNOWN_VAL = '*unknown*' + UNKNOWN_VAL = "*unknown*" - key = get_match.group('issue_key') + key = get_match.group("issue_key") jira_response = requests.get( - self.domain_with_protocol + '/rest/api/2/issue/' + key, - headers={'Authorization': self.auth}, + self.domain_with_protocol + "/rest/api/2/issue/" + key, + headers={"Authorization": self.auth}, ).json() - url = self.display_url + '/browse/' + key - errors = jira_response.get('errorMessages', []) - fields = jira_response.get('fields', {}) + url = self.display_url + "/browse/" + key + errors = jira_response.get("errorMessages", []) + fields = jira_response.get("fields", {}) - creator_name = fields.get('creator', {}).get('name', UNKNOWN_VAL) - description = fields.get('description', UNKNOWN_VAL) - priority_name = fields.get('priority', {}).get('name', UNKNOWN_VAL) - project_name = fields.get('project', {}).get('name', UNKNOWN_VAL) - type_name = fields.get('issuetype', {}).get('name', UNKNOWN_VAL) - status_name = fields.get('status', {}).get('name', UNKNOWN_VAL) - summary = fields.get('summary', UNKNOWN_VAL) + creator_name = fields.get("creator", {}).get("name", UNKNOWN_VAL) + description = fields.get("description", UNKNOWN_VAL) + priority_name = fields.get("priority", {}).get("name", UNKNOWN_VAL) + project_name = fields.get("project", {}).get("name", UNKNOWN_VAL) + type_name = fields.get("issuetype", {}).get("name", UNKNOWN_VAL) + status_name = fields.get("status", {}).get("name", UNKNOWN_VAL) + summary = fields.get("summary", UNKNOWN_VAL) if errors: - response = 'Oh no! Jira raised an error:\n > ' + ', '.join(errors) + response = "Oh no! Jira raised an error:\n > " + ", ".join(errors) else: response = ( - '**Issue *[{}]({})*: {}**\n\n' - ' - Type: *{}*\n' - ' - Description:\n' - ' > {}\n' - ' - Creator: *{}*\n' - ' - Project: *{}*\n' - ' - Priority: *{}*\n' - ' - Status: *{}*\n' - ).format(key, url, summary, type_name, description, creator_name, project_name, - priority_name, status_name) + "**Issue *[{}]({})*: {}**\n\n" + " - Type: *{}*\n" + " - Description:\n" + " > {}\n" + " - Creator: *{}*\n" + " - Project: *{}*\n" + " - Priority: *{}*\n" + " - Status: *{}*\n" + ).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')) + 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"), + ), ) jira_response_json = jira_response.json() if jira_response.text else {} - key = jira_response_json.get('key', '') - url = self.display_url + '/browse/' + key - errors = list(jira_response_json.get('errors', {}).values()) + key = jira_response_json.get("key", "") + url = self.display_url + "/browse/" + key + errors = list(jira_response_json.get("errors", {}).values()) if errors: - response = 'Oh no! Jira raised an error:\n > ' + ', '.join(errors) + response = "Oh no! Jira raised an error:\n > " + ", ".join(errors) else: - response = 'Issue *' + key + '* is up! ' + url + response = "Issue *" + key + "* is up! " + url elif edit_match and check_is_editing_something(edit_match): - key = edit_match.group('issue_key') + key = edit_match.group("issue_key") 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')) + 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"), + ), ) jira_response_json = jira_response.json() if jira_response.text else {} - url = self.display_url + '/browse/' + key - errors = list(jira_response_json.get('errors', {}).values()) + url = self.display_url + "/browse/" + key + errors = list(jira_response_json.get("errors", {}).values()) if errors: - response = 'Oh no! Jira raised an error:\n > ' + ', '.join(errors) + response = "Oh no! Jira raised an error:\n > " + ", ".join(errors) else: - response = 'Issue *' + key + '* was edited! ' + url + response = "Issue *" + key + "* was edited! " + url elif search_match: - search_term = search_match.group('search_term') - search_results = self.jql_search("summary ~ {}".format(search_term)) - response = '**Search results for "{}"**\n\n{}'.format(search_term, search_results) + search_term = search_match.group("search_term") + search_results = self.jql_search(f"summary ~ {search_term}") + response = f'**Search results for "{search_term}"**\n\n{search_results}' elif jql_match: - jql_query = jql_match.group('jql_query') + jql_query = jql_match.group("jql_query") search_results = self.jql_search(jql_query) - response = '**Search results for "{}"**\n\n{}'.format(jql_query, search_results) + response = f'**Search results for "{jql_query}"**\n\n{search_results}' elif help_match: response = HELP_RESPONSE else: - response = 'Sorry, I don\'t understand that! Send me `help` for instructions.' + response = "Sorry, I don't understand that! Send me `help` for instructions." 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: '. + """Makes an auth header for Jira in the form 'Basic: '. Parameters: - username: The Jira email address. - password: The Jira password. - ''' - combo = username + ':' + password - 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: - '''Makes a JSON string for the Jira REST API editing endpoint based on + """ + combo = username + ":" + password + 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: + """Makes a JSON string for the Jira REST API editing endpoint based on fields that could be edited. Parameters: @@ -336,36 +363,39 @@ def make_create_json(summary: str, project_key: str, type_name: str, - labels (optional): The Jira labels property, as a string of labels separated by comma-spaces. - due_date (optional): The Jira due date property. - ''' + """ json_fields = { - 'summary': summary, - 'project': { - 'key': project_key - }, - 'issuetype': { - 'name': type_name - } + "summary": summary, + "project": {"key": project_key}, + "issuetype": {"name": type_name}, } if description: - json_fields['description'] = description + json_fields["description"] = description if assignee: - json_fields['assignee'] = {'name': assignee} + json_fields["assignee"] = {"name": assignee} if priority_name: - json_fields['priority'] = {'name': priority_name} + json_fields["priority"] = {"name": priority_name} if labels: - json_fields['labels'] = labels.split(', ') + json_fields["labels"] = labels.split(", ") if due_date: - json_fields['duedate'] = due_date + json_fields["duedate"] = due_date - json = {'fields': json_fields} + json = {"fields": json_fields} 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: - '''Makes a JSON string for the Jira REST API editing endpoint based on + +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. Parameters: @@ -378,48 +408,50 @@ def make_edit_json(summary: Optional[str], project_key: Optional[str], - labels (optional): The Jira labels property, as a string of labels separated by comma-spaces. - due_date (optional): The Jira due date property. - ''' + """ json_fields = {} if summary: - json_fields['summary'] = summary + json_fields["summary"] = summary if project_key: - json_fields['project'] = {'key': project_key} + json_fields["project"] = {"key": project_key} if type_name: - json_fields['issuetype'] = {'name': type_name} + json_fields["issuetype"] = {"name": type_name} if description: - json_fields['description'] = description + json_fields["description"] = description if assignee: - json_fields['assignee'] = {'name': assignee} + json_fields["assignee"] = {"name": assignee} if priority_name: - json_fields['priority'] = {'name': priority_name} + json_fields["priority"] = {"name": priority_name} if labels: - json_fields['labels'] = labels.split(', ') + json_fields["labels"] = labels.split(", ") if due_date: - json_fields['duedate'] = due_date + json_fields["duedate"] = due_date - json = {'fields': json_fields} + json = {"fields": json_fields} return json + def check_is_editing_something(match: Any) -> bool: - '''Checks if an editing match is actually going to do editing. It is + """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 editing field is optional. For example, 'edit issue "BOTS-13"' would pass but wouldn't preform any actions. Parameters: - match: The regex match object. - ''' + """ return bool( - match.group('summary') - or match.group('project_key') - or match.group('type_name') - or match.group('description') - or match.group('assignee') - or match.group('priority_name') - or match.group('labels') - or match.group('due_date') + match.group("summary") + or match.group("project_key") + or match.group("type_name") + or match.group("description") + or match.group("assignee") + or match.group("priority_name") + or match.group("labels") + 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 6bd021eb13..1c7c092840 100644 --- a/zulip_bots/zulip_bots/bots/jira/test_jira.py +++ b/zulip_bots/zulip_bots/bots/jira/test_jira.py @@ -1,28 +1,29 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestJiraBot(BotTestCase, DefaultTests): - bot_name = 'jira' + bot_name = "jira" MOCK_CONFIG_INFO = { - 'username': 'example@example.com', - 'password': 'qwerty!123', - 'domain': 'example.atlassian.net' + "username": "example@example.com", + "password": "qwerty!123", + "domain": "example.atlassian.net", } MOCK_SCHEME_CONFIG_INFO = { - 'username': 'example@example.com', - 'password': 'qwerty!123', - 'domain': 'http://example.atlassian.net' + "username": "example@example.com", + "password": "qwerty!123", + "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' + "username": "example@example.com", + "password": "qwerty!123", + "domain": "example.atlassian.net", + "display_url": "http://test.com", } - MOCK_GET_RESPONSE = '''\ + MOCK_GET_RESPONSE = """\ **Issue *[TEST-13](https://example.atlassian.net/browse/TEST-13)*: summary** - Type: *Task* @@ -32,15 +33,15 @@ class TestJiraBot(BotTestCase, DefaultTests): - Project: *Tests* - Priority: *Medium* - Status: *To Do* -''' +""" - MOCK_CREATE_RESPONSE = 'Issue *TEST-16* is up! https://example.atlassian.net/browse/TEST-16' + MOCK_CREATE_RESPONSE = "Issue *TEST-16* is up! https://example.atlassian.net/browse/TEST-16" - MOCK_EDIT_RESPONSE = 'Issue *TEST-16* was edited! https://example.atlassian.net/browse/TEST-16' + MOCK_EDIT_RESPONSE = "Issue *TEST-16* was edited! https://example.atlassian.net/browse/TEST-16" - MOCK_NOTHING_RESPONSE = 'Sorry, I don\'t understand that! Send me `help` for instructions.' + MOCK_NOTHING_RESPONSE = "Sorry, I don't understand that! Send me `help` for instructions." - MOCK_HELP_RESPONSE = ''' + MOCK_HELP_RESPONSE = """ **get** `get` takes in an issue key and sends back information about that issue. For example, @@ -149,7 +150,7 @@ class TestJiraBot(BotTestCase, DefaultTests): Jira Bot: > Issue *BOTS-16* was edited! https://example.atlassian.net/browse/BOTS-16 -''' +""" MOCK_SEARCH_RESPONSE = '**Search results for "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]**' MOCK_SEARCH_RESPONSE_URL = '**Search results for "TEST"**\n\n*Found 2 results*\n\n\n - TEST-1: [summary test 1](http://test.com/browse/TEST-1) **[To Do]**\n - TEST-2: [summary test 2](http://test.com/browse/TEST-2) **[To Do]**' @@ -157,103 +158,111 @@ class TestJiraBot(BotTestCase, DefaultTests): 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: config_without_username = { - 'password': 'qwerty!123', - 'domain': 'example.atlassian.net', + "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', + "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', + "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: with self.mock_config_info(self.MOCK_CONFIG_INFO): - self.verify_reply('help', self.MOCK_HELP_RESPONSE) + self.verify_reply("help", self.MOCK_HELP_RESPONSE) # This overrides the default one in `BotTestCase`. def test_bot_responds_to_empty_message(self) -> None: with self.mock_config_info(self.MOCK_CONFIG_INFO): - self.verify_reply('', self.MOCK_NOTHING_RESPONSE) + self.verify_reply("", self.MOCK_NOTHING_RESPONSE) def test_no_command(self) -> None: with self.mock_config_info(self.MOCK_CONFIG_INFO): - self.verify_reply('qwertyuiop', self.MOCK_NOTHING_RESPONSE) + self.verify_reply("qwertyuiop", self.MOCK_NOTHING_RESPONSE) 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 c046fb84e1..39707df167 100644 --- a/zulip_bots/zulip_bots/bots/link_shortener/link_shortener.py +++ b/zulip_bots/zulip_bots/bots/link_shortener/link_shortener.py @@ -1,106 +1,108 @@ import re +from typing import Any, Dict + import requests -from typing import Any, Dict from zulip_bots.lib import BotHandler + class LinkShortenerHandler: - '''A Zulip bot that will shorten URLs ("links") in a conversation using the + """A Zulip bot that will shorten URLs ("links") in a conversation using the goo.gl URL shortener. - ''' + """ def usage(self) -> str: 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`.') + "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`." + ) def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('link_shortener') + self.config_info = bot_handler.get_config_info("link_shortener") self.check_api_key(bot_handler) def check_api_key(self, bot_handler: BotHandler) -> None: - test_request_data = self.call_link_shorten_service('www.youtube.com/watch') # type: Any + 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. + r"(" + r"(?:http|https):\/\/" # This allows for the HTTP or HTTPS + # protocol. r'[^"<>\{\}|\^~[\]` ]+' # This allows for any character except - # for certain non-URL-safe ones. - r')' + # for certain non-URL-safe ones. + r")" ) HELP_STR = ( - 'Mention the link shortener bot in a conversation and ' - 'then enter any URLs you want to shorten in the body of ' - 'the message.' + "Mention the link shortener bot in a conversation and " + "then enter any URLs you want to shorten in the body of " + "the message." ) - content = message['content'] + content = message["content"] - if content.strip() == 'help': - bot_handler.send_reply( - message, - HELP_STR - ) + if content.strip() == "help": + bot_handler.send_reply(message, HELP_STR) return link_matches = re.findall(REGEX_STR, content) 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) - if shortened_link != '' + (link_match + ": " + shortened_link) + for link_match, shortened_link in zip(link_matches, shortened_links) + if shortened_link != "" ] - final_response = '\n'.join(link_pairs) + final_response = "\n".join(link_pairs) - if final_response == '': - bot_handler.send_reply( - message, - 'No links found. ' + HELP_STR - ) + if final_response == "": + bot_handler.send_reply(message, "No links found. " + HELP_STR) return bot_handler.send_reply(message, final_response) def shorten_link(self, long_url: str) -> str: - '''Shortens a link using goo.gl Link Shortener and returns it, or + """Shortens a link using goo.gl Link Shortener and returns it, or returns an empty string if something goes wrong. Parameters: long_url (str): The original URL to shorten. - ''' + """ response_json = self.call_link_shorten_service(long_url) - if response_json['status_code'] == 200 and self.has_shorten_url(response_json): + if response_json["status_code"] == 200 and self.has_shorten_url(response_json): shorten_url = self.get_shorten_url(response_json) else: - shorten_url = '' + shorten_url = "" return shorten_url 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} + "https://api-ssl.bitly.com/v3/shorten", + params={"access_token": self.config_info["key"], "longUrl": long_url}, ) return response.json() def has_shorten_url(self, response_json: Any) -> bool: - return 'data' in response_json and 'url' in response_json['data'] + return "data" in response_json and "url" in response_json["data"] def get_shorten_url(self, response_json: Any) -> str: - return response_json['data']['url'] + 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 ee00ab4820..01a6fd5176 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 @@ -1,52 +1,66 @@ from unittest.mock import patch -from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.test_lib import StubBotHandler + from zulip_bots.bots.link_shortener.link_shortener import LinkShortenerHandler +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler class TestLinkShortenerBot(BotTestCase, DefaultTests): bot_name = "link_shortener" def _test(self, message: str, response: str) -> None: - with self.mock_config_info({'key': 'qwertyuiop'}): + with self.mock_config_info({"key": "qwertyuiop"}): self.verify_reply(message, response) 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.')) + 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." + ), + ) 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') + 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", + ) 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.')) + 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." + ), + ) 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.')) + 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." + ), + ) def test_exception_when_api_key_is_invalid(self) -> None: bot_test_instance = LinkShortenerHandler() - with self.mock_config_info({'key': 'qwertyuiopx'}): - with self.mock_http_conversation('test_invalid_access_token'): + with self.mock_config_info({"key": "qwertyuiopx"}): + with self.mock_http_conversation("test_invalid_access_token"): with self.assertRaises(StubBotHandler.BotQuitException): bot_test_instance.initialize(StubBotHandler()) diff --git a/zulip_bots/zulip_bots/bots/mention/mention.py b/zulip_bots/zulip_bots/bots/mention/mention.py index 166c7940c7..253dcc5ce8 100644 --- a/zulip_bots/zulip_bots/bots/mention/mention.py +++ b/zulip_bots/zulip_bots/bots/mention/mention.py @@ -1,106 +1,115 @@ # See readme.md for instructions on running this code. +from typing import Any, Dict, List + import requests -from typing import Any, List, Dict + from zulip_bots.lib import BotHandler + class MentionHandler: def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('mention') - self.access_token = self.config_info['access_token'] - self.account_id = '' + self.config_info = bot_handler.get_config_info("mention") + self.access_token = self.config_info["access_token"] + self.account_id = "" self.check_access_token(bot_handler) def check_access_token(self, bot_handler: BotHandler) -> None: test_query_header = { - 'Authorization': 'Bearer ' + self.access_token, - 'Accept-Version': '1.15', + "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 def usage(self) -> str: - return ''' + return """ This is a Mention API Bot which will find mentions of the given keyword throughout the web. Version 1.00 - ''' + """ def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: - message['content'] = message['content'].strip() + message["content"] = message["content"].strip() - if message['content'].lower() == 'help': + if message["content"].lower() == "help": bot_handler.send_reply(message, self.usage()) return - if message['content'] == '': - bot_handler.send_reply(message, 'Empty Mention Query') + if message["content"] == "": + bot_handler.send_reply(message, "Empty Mention Query") return - keyword = message['content'] + keyword = message["content"] content = self.generate_response(keyword) bot_handler.send_reply(message, content) def get_account_id(self) -> str: get_ac_id_header = { - 'Authorization': 'Bearer ' + self.access_token, - 'Accept-Version': '1.15', + "Authorization": "Bearer " + self.access_token, + "Accept-Version": "1.15", } - response = requests.get('https://api.mention.net/api/accounts/me', headers=get_ac_id_header) + response = requests.get("https://api.mention.net/api/accounts/me", headers=get_ac_id_header) data_json = response.json() - account_id = data_json['account']['id'] + account_id = data_json["account"]["id"] return account_id def get_alert_id(self, keyword: str) -> str: create_alert_header = { - 'Authorization': 'Bearer ' + self.access_token, - 'Content-Type': 'application/json', - 'Accept-Version': '1.15', + "Authorization": "Bearer " + self.access_token, + "Content-Type": "application/json", + "Accept-Version": "1.15", } create_alert_data = { - 'name': keyword, - 'query': { - 'type': 'basic', - 'included_keywords': [keyword] - }, - 'languages': ['en'], - 'sources': ['web'] + "name": keyword, + "query": {"type": "basic", "included_keywords": [keyword]}, + "languages": ["en"], + "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'] + alert_id = data_json["alert"]["id"] return alert_id def get_mentions(self, alert_id: str) -> List[Any]: get_mentions_header = { - 'Authorization': 'Bearer ' + self.access_token, - 'Accept-Version': '1.15', + "Authorization": "Bearer " + self.access_token, + "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() - mentions = data_json['mentions'] + mentions = data_json["mentions"] return mentions def generate_response(self, keyword: str) -> str: - if self.account_id == '': + if self.account_id == "": self.account_id = self.get_account_id() try: @@ -115,12 +124,14 @@ def generate_response(self, keyword: str) -> str: # Usually triggered by no response or json parse error when account quota is finished. raise MentionNoResponseException() - reply = 'The most recent mentions of `' + keyword + '` on the web are: \n' + reply = "The most recent mentions of `" + keyword + "` on the web are: \n" for mention in mentions: - reply += "[{title}]({id})\n".format(title=mention['title'], id=mention['original_url']) + 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 1aac508196..5a9b5a426b 100644 --- a/zulip_bots/zulip_bots/bots/mention/test_mention.py +++ b/zulip_bots/zulip_bots/bots/mention/test_mention.py @@ -1,54 +1,56 @@ -from zulip_bots.bots.mention.mention import MentionHandler from unittest.mock import patch -from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.test_lib import StubBotHandler + +from zulip_bots.bots.mention.mention import MentionHandler +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler + 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'): - self.verify_reply('', 'Empty Mention Query') + 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() - bot_test_instance.access_token = 'TEST' + bot_test_instance.access_token = "TEST" - with self.mock_http_conversation('get_account_id'): - self.assertEqual(bot_test_instance.get_account_id(), 'TEST') + with self.mock_http_conversation("get_account_id"): + self.assertEqual(bot_test_instance.get_account_id(), "TEST") def test_get_alert_id(self) -> None: bot_test_instance = MentionHandler() - bot_test_instance.access_token = 'TEST' - bot_test_instance.account_id = 'TEST' + bot_test_instance.access_token = "TEST" + bot_test_instance.account_id = "TEST" - with self.mock_http_conversation('get_alert_id'): - self.assertEqual(bot_test_instance.get_alert_id('TEST'), 'TEST') + with self.mock_http_conversation("get_alert_id"): + self.assertEqual(bot_test_instance.get_alert_id("TEST"), "TEST") def test_get_mentions(self) -> None: bot_test_instance = MentionHandler() - bot_test_instance.access_token = 'TEST' - bot_test_instance.account_id = 'TEST' + bot_test_instance.access_token = "TEST" + bot_test_instance.account_id = "TEST" - with self.mock_http_conversation('get_mentions'): - bot_response = bot_test_instance.get_mentions('TEST')[0] - self.assertEqual(bot_response['title'], 'TEST') - self.assertEqual(bot_response['original_url'], 'TEST') + with self.mock_http_conversation("get_mentions"): + bot_response = bot_test_instance.get_mentions("TEST")[0] + self.assertEqual(bot_response["title"], "TEST") + self.assertEqual(bot_response["original_url"], "TEST") def test_exception_when_api_key_is_invalid(self) -> None: bot_test_instance = MentionHandler() - with self.mock_config_info({'access_token': 'TEST'}): - with self.mock_http_conversation('invalid_api_key'): + with self.mock_config_info({"access_token": "TEST"}): + with self.mock_http_conversation("invalid_api_key"): with self.assertRaises(StubBotHandler.BotQuitException): bot_test_instance.initialize(StubBotHandler()) diff --git a/zulip_bots/zulip_bots/bots/merels/libraries/constants.py b/zulip_bots/zulip_bots/bots/merels/libraries/constants.py index 3936bcfc90..c567b86e7c 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 bfef128879..f0d64d21f4 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 @@ import json -class MerelsStorage(): +class MerelsStorage: def __init__(self, topic_name, storage): """Instantiate storage field. @@ -28,9 +28,8 @@ def __init__(self, topic_name, storage): """ 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 @@ def update_game(self, topic_name, turn, x_taken, o_taken, board, hill_uid, :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 @@ def get_game_data(self, topic_name): 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 1822d29c20..7ee44e014b 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/game.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/game.py @@ -9,21 +9,21 @@ from zulip_bots.game_handler import BadMoveException -from . import database -from . import mechanics -COMMAND_PATTERN = re.compile( - "^(\\w*).*(\\d,\\d).*(\\d,\\d)|^(\\w+).*(\\d,\\d)") +from . import database, mechanics + +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 @@ def getHelp(): 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 @@ -198,5 +197,5 @@ def check_win(topic_name, merels_storage): win = mechanics.who_won(topic_name, merels_storage) if win != "None": merels.remove_game(topic_name) - return "{} wins the game!".format(win) + return f"{win} wins the game!" return "" 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 4a1dbe9204..c2eadf221d 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 .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 @@ def construct(self): """ 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 @@ def get_phase(self): :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 55a031efd8..842d864981 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/interface.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/interface.py @@ -42,7 +42,7 @@ def graph_grid(grid): :return: A nicer display of the grid """ - return '''` 0 1 2 3 4 5 6 + return """` 0 1 2 3 4 5 6 0 [{}]---------------[{}]---------------[{}] | | | 1 | [{}]---------[{}]---------[{}] | @@ -55,14 +55,32 @@ 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]) + 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], + ) 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 1018d71963..ecd2d75642 100644 --- a/zulip_bots/zulip_bots/bots/merels/libraries/mechanics.py +++ b/zulip_bots/zulip_bots/bots/merels/libraries/mechanics.py @@ -2,16 +2,14 @@ mechanisms as well as some functions for accessing the database. """ -from math import sqrt - from collections import Counter +from math import sqrt -from . import constants -from . import database -from . import game_data -from . import interface from zulip_bots.game_handler import BadMoveException +from . import constants, database, game_data, interface + + def is_in_grid(vertical_pos, horizontal_pos): """Checks whether the cell actually exists or not @@ -54,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: @@ -85,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 @@ -150,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): @@ -197,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): @@ -241,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 @@ -255,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: @@ -279,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 += f"A room has been created in {topic_name}. Starting game now.\n" 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): @@ -311,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 @@ -326,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): @@ -346,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 @@ -355,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 @@ -387,10 +395,16 @@ 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) - return "Put a man to ({}, {}) for {}.".format(v, h, data.turn) + merels.update_game( + data.topic_name, + data.turn, + data.x_taken, + data.o_taken, + data.board, + data.hill_uid, + data.take_mode, + ) + return f"Put a man to ({v}, {h}) for {data.turn}." else: raise BadMoveException("Failed: That's not a legal put. Please try again.") @@ -425,10 +439,16 @@ 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) - return "Taken a man from ({}, {}) for {}.".format(v, h, data.turn) + merels.update_game( + data.topic_name, + data.turn, + data.x_taken, + data.o_taken, + data.board, + data.hill_uid, + data.take_mode, + ) + return f"Taken a man from ({v}, {h}) for {data.turn}." else: raise BadMoveException("Failed: That's not a legal take. Please try again.") @@ -446,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): @@ -464,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): @@ -482,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): @@ -536,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 85d4e38964..e40832ea3b 100644 --- a/zulip_bots/zulip_bots/bots/merels/merels.py +++ b/zulip_bots/zulip_bots/bots/merels/merels.py @@ -1,12 +1,9 @@ -from typing import List, Any -from zulip_bots.bots.merels.libraries import ( - game, - mechanics, - database, - game_data -) +from typing import Any, List + +from zulip_bots.bots.merels.libraries import database, game, game_data, mechanics from zulip_bots.game_handler import GameAdapter, SamePlayerMove + class Storage: data = {} @@ -19,26 +16,27 @@ def put(self, topic_name, value: str): 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) self.current_board = mechanics.display_game(self.topic, self.storage) - self.token = ['O', 'X'] + self.token = ["O", "X"] def determine_game_over(self, players: List[str]) -> str: if self.contains_winning_move(self.current_board): - return 'current turn' - return '' + return "current turn" + return "" def contains_winning_move(self, board: Any) -> bool: merels = database.MerelsStorage(self.topic, self.storage) 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 @@ -46,16 +44,16 @@ def make_move(self, move: str, player_number: int, computer_move: bool = False) 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] - )) + f'["{self.token[player_number]}", 0, 0, "NNNNNNNNNNNNNNNNNNNNNNNN", "", 0]', + ) 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:'] + tokens = [":o_button:", ":cross_mark_button:"] def parse_board(self, board: Any) -> str: return board @@ -69,24 +67,26 @@ def alert_move_message(self, original_player: str, move_info: str) -> str: 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.', + "name": "merels", + "description": "Lets you play merels against any player.", } def usage(self) -> str: return game.getInfo() def __init__(self) -> None: - game_name = 'Merels' - bot_name = 'merels' + game_name = "Merels" + bot_name = "merels" move_help_message = "" - move_regex = '.*' + move_regex = ".*" model = MerelsModel rules = game.getInfo() gameMessageHandler = MerelsMessageHandler @@ -98,9 +98,10 @@ def __init__(self) -> None: 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 80ab8c798b..62a3b13c7c 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 @@ 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]], - ) - - self.assertEqual(constants.HILLS, relative_hills, - "Incorrect relative hills arrangement") + 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") 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 c3b37689db..8a7cfa194b 100644 --- a/zulip_bots/zulip_bots/bots/merels/test/test_database.py +++ b/zulip_bots/zulip_bots/bots/merels/test/test_database.py @@ -1,21 +1,20 @@ +from libraries import database, game_data -from libraries import database -from libraries import game_data from zulip_bots.simple_lib import SimpleStorage from zulip_bots.test_lib import BotTestCase, DefaultTests + class DatabaseTest(BotTestCase, DefaultTests): - bot_name = 'merels' + bot_name = "merels" def setUp(self): self.storage = SimpleStorage() self.merels = database.MerelsStorage("", self.storage) def test_obtain_gamedata(self): - self.merels.update_game("topic1", "X", 0, 0, 'NNNNNNNNNNNNNNNNNNNNNNNN', "", 0) + 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): @@ -23,13 +22,13 @@ def test_obtain_nonexisting_gamedata(self): self.assertEqual(res, None) def test_game_session(self): - self.merels.update_game("topic1", "X", 0, 0, 'NNNNNNNNNNNNNNNNNNNNNNNN', "", 0) - self.merels.update_game("topic2", "O", 5, 4, 'XXXXOOOOONNNNNNNNNNNNNNN', "", 0) + self.merels.update_game("topic1", "X", 0, 0, "NNNNNNNNNNNNNNNNNNNNNNNN", "", 0) + self.merels.update_game("topic2", "O", 5, 4, "XXXXOOOOONNNNNNNNNNNNNNN", "", 0) self.assertTrue(self.storage.contains("topic1"), self.storage.contains("topic2")) topic2Board = game_data.GameData(self.merels.get_game_data("topic2")) self.assertEqual(topic2Board.board, "XXXXOOOOONNNNNNNNNNNNNNN") def test_remove_game(self): - self.merels.update_game("topic1", "X", 0, 0, 'NNNNNNNNNNNNNNNNNNNNNNNN', "", 0) + self.merels.update_game("topic1", "X", 0, 0, "NNNNNNNNNNNNNNNNNNNNNNNN", "", 0) self.merels.remove_game("topic1") self.assertEqual(self.merels.get_game_data("topic1"), None) diff --git a/zulip_bots/zulip_bots/bots/merels/test/test_game.py b/zulip_bots/zulip_bots/bots/merels/test/test_game.py index 35e2395b05..31b4164153 100644 --- a/zulip_bots/zulip_bots/bots/merels/test/test_game.py +++ b/zulip_bots/zulip_bots/bots/merels/test/test_game.py @@ -1,9 +1,10 @@ import unittest -from libraries import game -from libraries import database -from zulip_bots.simple_lib import SimpleStorage +from libraries import database, game + from zulip_bots.game_handler import BadMoveException +from zulip_bots.simple_lib import SimpleStorage + class GameTest(unittest.TestCase): def setUp(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 a8e571d64c..cf6e3d5814 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 @@ 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 @@ def test_empty_layout_arrangement(self): | | | | | 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 @@ def test_full_layout_arragement(self): | | | | | 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 @@ def test_illegal_character_arrangement(self): | | | | | 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 6073b3b782..e8ab014088 100644 --- a/zulip_bots/zulip_bots/bots/merels/test/test_mechanics.py +++ b/zulip_bots/zulip_bots/bots/merels/test/test_mechanics.py @@ -1,125 +1,203 @@ import unittest -from libraries import database -from libraries import game_data -from libraries import interface -from libraries import mechanics +from libraries import database, game_data, interface, mechanics + 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] - - test_outcomes = [mechanics.is_in_grid(point[0], point[1]) for point in - points] + 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] 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]] - - expected_outcomes = [False, False, False, True, True, True, True, True, - True, False, True, False, False] + 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, + ] 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) @@ -174,14 +252,12 @@ def test_new_game_phase(self): 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 f37d5fcce7..c64d86ff79 100644 --- a/zulip_bots/zulip_bots/bots/merels/test_merels.py +++ b/zulip_bots/zulip_bots/bots/merels/test_merels.py @@ -1,16 +1,22 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.game_handler import GameInstance +from typing import Any, List, Tuple + from libraries.constants import EMPTY_BOARD -from typing import List, Tuple, Any +from zulip_bots.game_handler import GameInstance +from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestMerelsBot(BotTestCase, DefaultTests): - bot_name = 'merels' + 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 @@ -21,21 +27,23 @@ def test_static_responses(self) -> None: 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: model, message_handler = self._get_game_handlers() # Attributes from the Merels Handler - self.assertTrue(hasattr(message_handler, 'parse_board') is not None) - self.assertTrue(hasattr(message_handler, 'get_player_color') is not None) - self.assertTrue(hasattr(message_handler, 'alert_move_message') is not None) - self.assertTrue(hasattr(message_handler, 'game_start_message') is not None) - self.assertTrue(hasattr(message_handler, 'alert_move_message') is not None) + self.assertTrue(hasattr(message_handler, "parse_board") is not None) + self.assertTrue(hasattr(message_handler, "get_player_color") is not None) + self.assertTrue(hasattr(message_handler, "alert_move_message") is not None) + self.assertTrue(hasattr(message_handler, "game_start_message") is not None) + self.assertTrue(hasattr(message_handler, "alert_move_message") is not None) # Attributes from the Merels Model - self.assertTrue(hasattr(model, 'determine_game_over') is not None) - self.assertTrue(hasattr(model, 'contains_winning_move') is not None) - self.assertTrue(hasattr(model, 'make_move') is not None) + self.assertTrue(hasattr(model, "determine_game_over") is not None) + self.assertTrue(hasattr(model, "contains_winning_move") is not None) + self.assertTrue(hasattr(model, "make_move") is not None) def test_parse_board(self) -> None: board = EMPTY_BOARD @@ -52,17 +60,19 @@ def add_user_to_cache(self, name: str, bot: Any = None) -> Any: if bot is None: bot, bot_handler = self._get_handlers() message = { - 'sender_email': '{}@example.com'.format(name), - 'sender_full_name': '{}'.format(name)} + "sender_email": f"{name}@example.com", + "sender_full_name": f"{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') - bot.instances.update({'abc123': instance}) + 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" + ) + bot.instances.update({"abc123": instance}) instance.start() return bot @@ -75,7 +85,9 @@ def _test_parse_board(self, board: str, expected_response: str) -> None: 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 90b795d39e..8753ec3686 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 b6487d6bce..9866f484c8 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/lib/parse.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/lib/parse.py @@ -3,13 +3,11 @@ """ from json.decoder import JSONDecodeError -from typing import Text -from zulip_bots.bots.monkeytestit.lib import extract -from zulip_bots.bots.monkeytestit.lib import report +from zulip_bots.bots.monkeytestit.lib import extract, report -def execute(message: Text, apikey: Text) -> Text: +def execute(message: str, apikey: str) -> str: """Parses message and returns a dictionary :param message: The message @@ -24,12 +22,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.") - - options = {"secret": apikey, "url": params[1], "on_load": "true", - "on_click": "true", "page_weight": "true", "seo": "true", - "broken_links": "true", "asset_count": "true"} + 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", + } # Set the options only if supplied @@ -49,9 +53,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) @@ -59,11 +65,10 @@ 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: +def failed(message: str) -> str: """Simply attaches a failed marker to a message :param message: The message diff --git a/zulip_bots/zulip_bots/bots/monkeytestit/lib/report.py b/zulip_bots/zulip_bots/bots/monkeytestit/lib/report.py index 4e03af3745..9b0e2f01c7 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/lib/report.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/lib/report.py @@ -1,10 +1,10 @@ """Used to mainly compose a decorated report for the user """ -from typing import Dict, Text, List +from typing import Dict, List -def compose(results: Dict) -> Text: +def compose(results: Dict) -> str: """Composes a report based on test results An example would be: @@ -21,24 +21,24 @@ def compose(results: Dict) -> Text: :return: A response string containing the full report """ if "error" in results: - return "Error: {}".format(results['error']) + return "Error: {}".format(results["error"]) response = "" - response += "{}\n".format(print_status(results)) + response += f"{print_status(results)}\n" if "success" in response.lower(): - response += "{}".format(print_test_id(results)) + response += f"{print_test_id(results)}" return response - response += "{}\n".format(print_enabled_checkers(results)) - response += "{}\n".format(print_failures_checkers(results)) - response += "{}".format(print_more_info_url(results)) + response += f"{print_enabled_checkers(results)}\n" + response += f"{print_failures_checkers(results)}\n" + response += f"{print_more_info_url(results)}" return response -def print_more_info_url(results: Dict) -> Text: +def print_more_info_url(results: Dict) -> str: """Creates info for the test URL from monkeytest.it Example: @@ -48,19 +48,19 @@ def print_more_info_url(results: Dict) -> Text: :param results: A dictionary containing the results of a check :return: A response string containing the url info """ - return "More info: {}".format(results['results_url']) + return "More info: {}".format(results["results_url"]) -def print_test_id(results: Dict) -> Text: +def print_test_id(results: Dict) -> str: """Prints the test-id with attached to the url :param results: A dictionary containing the results of a check :return: A response string containing the test id """ - return "Test: https://monkeytest.it/test/{}".format(results['test_id']) + return "Test: https://monkeytest.it/test/{}".format(results["test_id"]) -def print_failures_checkers(results: Dict) -> Text: +def print_failures_checkers(results: Dict) -> str: """Creates info for failures in enabled checkers Example: @@ -74,16 +74,18 @@ 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 = [ + f"{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) + return f"Failures from checkers: {failures_checkers_message}" def get_enabled_checkers(results: Dict) -> List: @@ -95,7 +97,7 @@ def get_enabled_checkers(results: Dict) -> List: :param results: A dictionary containing the results of a check :return: A list containing enabled checkers """ - checkers = results['enabled_checkers'] + checkers = results["enabled_checkers"] enabled_checkers = [] for checker in checkers.keys(): if checkers[checker]: # == True/False @@ -103,7 +105,7 @@ def get_enabled_checkers(results: Dict) -> List: return enabled_checkers -def print_enabled_checkers(results: Dict) -> Text: +def print_enabled_checkers(results: Dict) -> str: """Creates info for enabled checkers. This joins the list of enabled checkers and format it with the current string response @@ -113,11 +115,10 @@ 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: +def print_status(results: Dict) -> str: """Creates info for the check status. Example: Status: tests_failed @@ -125,4 +126,4 @@ def print_status(results: Dict) -> Text: :param results: A dictionary containing the results of a check :return: A response string containing check status """ - return "Status: {}".format(results['status']) + return "Status: {}".format(results["status"]) diff --git a/zulip_bots/zulip_bots/bots/monkeytestit/monkeytestit.py b/zulip_bots/zulip_bots/bots/monkeytestit/monkeytestit.py index 6474c31c15..0aab188467 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/monkeytestit.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/monkeytestit.py @@ -1,8 +1,8 @@ import logging from typing import Dict -from zulip_bots.lib import BotHandler + from zulip_bots.bots.monkeytestit.lib import parse -from zulip_bots.lib import NoBotConfigException +from zulip_bots.lib import BotHandler, NoBotConfigException class MonkeyTestitBot: @@ -11,38 +11,44 @@ def __init__(self): 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') + 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') + 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: - content = message['content'] + 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 c4b53b7b25..1ee4fc103e 100644 --- a/zulip_bots/zulip_bots/bots/monkeytestit/test_monkeytestit.py +++ b/zulip_bots/zulip_bots/bots/monkeytestit/test_monkeytestit.py @@ -1,43 +1,45 @@ +from importlib import import_module from unittest.mock import patch -from importlib import import_module from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestMonkeyTestitBot(BotTestCase, DefaultTests): bot_name = "monkeytestit" 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( - content='', - type='stream', + content="", + type="stream", ) - with patch.object(self.monkeytestit_class, 'initialize', return_value=None): - with self.mock_config_info({'api_key': "magic"}): + with patch.object(self.monkeytestit_class, "initialize", return_value=None): + with self.mock_config_info({"api_key": "magic"}): res = self.get_response(message) - self.assertTrue("Unknown command" in res['content']) + self.assertTrue("Unknown command" in res["content"]) def test_website_fail(self): message = dict( - content='check https://website.com', - type='stream', + content="check https://website.com", + type="stream", ) - with patch.object(self.monkeytestit_class, 'initialize', return_value=None): - with self.mock_config_info({'api_key': "magic"}): - with self.mock_http_conversation('website_result_fail'): + with patch.object(self.monkeytestit_class, "initialize", return_value=None): + with self.mock_config_info({"api_key": "magic"}): + with self.mock_http_conversation("website_result_fail"): res = self.get_response(message) - self.assertTrue("Status: tests_failed" in res['content']) + self.assertTrue("Status: tests_failed" in res["content"]) def test_website_success(self): message = dict( - content='check https://website.com', - type='stream', + content="check https://website.com", + type="stream", ) - with patch.object(self.monkeytestit_class, 'initialize', return_value=None): - with self.mock_config_info({'api_key': "magic"}): - with self.mock_http_conversation('website_result_success'): + with patch.object(self.monkeytestit_class, "initialize", return_value=None): + with self.mock_config_info({"api_key": "magic"}): + with self.mock_http_conversation("website_result_success"): res = self.get_response(message) - self.assertTrue("success" in res['content']) + self.assertTrue("success" in res["content"]) diff --git a/zulip_bots/zulip_bots/bots/salesforce/salesforce.py b/zulip_bots/zulip_bots/bots/salesforce/salesforce.py index bc766c39e1..87fad94db6 100644 --- a/zulip_bots/zulip_bots/bots/salesforce/salesforce.py +++ b/zulip_bots/zulip_bots/bots/salesforce/salesforce.py @@ -1,13 +1,15 @@ # See readme.md for instructions on running this code. +import logging +import re +from typing import Any, Dict, List + import simple_salesforce -from typing import Dict, Any, List + +from zulip_bots.bots.salesforce.utils import commands, default_query, link_query, object_types from zulip_bots.lib import BotHandler -import re -import logging -from zulip_bots.bots.salesforce.utils import commands, object_types, link_query, default_query -base_help_text = '''Salesforce bot +base_help_text = """Salesforce bot This bot can do simple salesforce query requests **All commands must be @-mentioned to the bot.** Commands: @@ -21,109 +23,116 @@ Supported Object types: These are the types of Salesforce object supported by this bot. The bot cannot show the details of any other object types. -{}''' +{}""" -login_url = 'https://login.salesforce.com/' +login_url = "https://login.salesforce.com/" def get_help_text() -> str: - command_text = '' + 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']) - object_type_text = '' + if "template" in command.keys() and "description" in command.keys(): + 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']) + object_type_text += "{}\n".format(object_type["table"]) return base_help_text.format(command_text, object_type_text) 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 = '' - if result['totalSize'] == 0: - return 'No records found.' - if result['totalSize'] == 1: - record = result['records'][0] - output += '**[{}]({}{})**\n'.format(record['Name'], - login_url, record['Id']) + exclude_keys += ["Name", "attributes", "Id"] + output = "" + if result["totalSize"] == 0: + return "No records found." + if result["totalSize"] == 1: + record = result["records"][0] + 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) + output += f">**{key}**: {value}\n" else: - for i, record in enumerate(result['records']): + for i, record in enumerate(result["records"]): if rank_output: - output += '{}) '.format(i + 1) - output += '**[{}]({}{})**\n'.format(record['Name'], - login_url, record['Id']) + output += f"{i + 1}) " + 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): added_keys = True - output += '>**{}**: {}\n'.format(key, value) + output += f">**{key}**: {value}\n" if added_keys: - output += '\n' + output += "\n" 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] + qarg = arg.split(" -", 1)[0] split_args = [] # type: List[str] - raw_arg = '' - if len(arg.split(' -', 1)) > 1: - raw_arg = ' -' + arg.split(' -', 1)[1] - split_args = raw_arg.split(' -') + raw_arg = "" + if len(arg.split(" -", 1)) > 1: + raw_arg = " -" + arg.split(" -", 1)[1] + split_args = raw_arg.split(" -") limit_num = 5 - re_limit = re.compile(r'-limit \d+') + re_limit = re.compile(r"-limit \d+") limit = re_limit.search(raw_arg) if limit: - limit_num = int(limit.group().rsplit(' ', 1)[1]) - logging.info('Searching with limit {}'.format(limit_num)) + limit_num = int(limit.group().rsplit(" ", 1)[1]) + logging.info(f"Searching with limit {limit_num}") query = default_query - 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)) + 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) + ) exclude_keys = [] # type: List[str] - if 'exclude_keys' in command.keys(): - exclude_keys = command['exclude_keys'] + if "exclude_keys" in command.keys(): + exclude_keys = command["exclude_keys"] force_keys = [] # type: List[str] - if 'force_keys' in command.keys(): - force_keys = command['force_keys'] + if "force_keys" in command.keys(): + force_keys = command["force_keys"] rank_output = False - if 'rank_output' in command.keys(): - rank_output = command['rank_output'] - 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) + if "rank_output" in command.keys(): + rank_output = command["rank_output"] + 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, + ) def get_salesforce_link_details(link: str, sf: Any) -> str: - re_id = re.compile('/[A-Za-z0-9]{18}') + re_id = re.compile("/[A-Za-z0-9]{18}") re_id_res = re_id.search(link) if re_id_res is None: - return 'Invalid salesforce link' - id = re_id_res.group().strip('/') + 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)) - if res['totalSize'] == 1: + 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.' + return "No object found. Make sure it is of the supported types. Type `help` for more info." class SalesforceHandler: def usage(self) -> str: - return ''' + return """ This is a Salesforce bot, which can search for Contacts, Accounts and Opportunities. And can be configured for any other object types. @@ -131,44 +140,44 @@ def usage(self) -> str: It will also show details of any Salesforce links posted. @-mention the bot with 'help' to see available commands. - ''' + """ def get_salesforce_response(self, content: str) -> str: content = content.strip() - if content == '' or content == 'help': + if content == "" or content == "help": return get_help_text() - if content.startswith('http') and 'force' in content: + if content.startswith("http") and "force" in content: return get_salesforce_link_details(content, self.sf) for command in commands: - for command_keyword in command['commands']: + for command_keyword in command["commands"]: if content.startswith(command_keyword): - args = content.replace(command_keyword, '').strip() - if args is not None and args != '': - if 'callback' in command.keys(): - return command['callback'](args, self.sf, command) + args = content.replace(command_keyword, "").strip() + if args is not None and args != "": + if "callback" in command.keys(): + return command["callback"](args, self.sf, command) else: return query_salesforce(args, self.sf, command) else: - return 'Usage: {} [arguments]'.format(command['template']) + return "Usage: {} [arguments]".format(command["template"]) return get_help_text() def initialize(self, bot_handler: BotHandler) -> None: - self.config_info = bot_handler.get_config_info('salesforce') + self.config_info = bot_handler.get_config_info("salesforce") try: self.sf = simple_salesforce.Salesforce( - username=self.config_info['username'], - password=self.config_info['password'], - security_token=self.config_info['security_token'] + username=self.config_info["username"], + password=self.config_info["password"], + 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)) + bot_handler.quit(f"Failed to log in to Salesforce. {err.code} {err.message}") def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: try: - bot_response = self.get_salesforce_response(message['content']) + bot_response = self.get_salesforce_response(message["content"]) bot_handler.send_reply(message, bot_response) except Exception as e: - bot_handler.send_reply(message, 'Error. {}.'.format(e), bot_response) + bot_handler.send_reply(message, f"Error. {e}.", bot_response) handler_class = SalesforceHandler diff --git a/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py b/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py index ed4ca262f7..23ab65fe3b 100644 --- a/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py +++ b/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py @@ -1,16 +1,18 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, read_bot_fixture_data -from simple_salesforce.exceptions import SalesforceAuthenticationFailed from contextlib import contextmanager -from unittest.mock import patch from typing import Any, Dict, Iterator +from unittest.mock import patch + +from simple_salesforce.exceptions import SalesforceAuthenticationFailed + +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, read_bot_fixture_data @contextmanager def mock_salesforce_query(test_name: str, bot_name: str) -> Iterator[None]: response_data = read_bot_fixture_data(bot_name, test_name) - sf_response = response_data.get('response') + sf_response = response_data.get("response") - with patch('simple_salesforce.api.Salesforce.query') as mock_query: + with patch("simple_salesforce.api.Salesforce.query") as mock_query: mock_query.return_value = sf_response yield @@ -18,13 +20,13 @@ def mock_salesforce_query(test_name: str, bot_name: str) -> Iterator[None]: @contextmanager def mock_salesforce_auth(is_success: bool) -> Iterator[None]: if is_success: - with patch('simple_salesforce.api.Salesforce.__init__') as mock_sf_init: + with patch("simple_salesforce.api.Salesforce.__init__") as mock_sf_init: mock_sf_init.return_value = None yield else: with patch( - 'simple_salesforce.api.Salesforce.__init__', - side_effect=SalesforceAuthenticationFailed(403, 'auth failed') + "simple_salesforce.api.Salesforce.__init__", + side_effect=SalesforceAuthenticationFailed(403, "auth failed"), ) as mock_sf_init: mock_sf_init.return_value = None yield @@ -32,18 +34,15 @@ 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 +help_text = """Salesforce bot This bot can do simple salesforce query requests **All commands must be @-mentioned to the bot.** Commands: @@ -61,7 +60,7 @@ def mock_salesforce_commands_types() -> Iterator[None]: The bot cannot show the details of any other object types. Table Table -''' +""" def echo(arg: str, sf: Any, command: Dict[str, Any]) -> str: @@ -70,38 +69,29 @@ def echo(arg: str, sf: Any, command: Dict[str, Any]) -> str: mock_commands = [ { - 'commands': ['find contact'], - 'object': 'contact', - 'description': 'finds contacts', - 'template': 'find contact ', + "commands": ["find contact"], + "object": "contact", + "description": "finds contacts", + "template": "find contact ", }, { - 'commands': ['find top opportunities'], - 'object': 'opportunity', - 'query': 'SELECT {} FROM {} WHERE isClosed=false ORDER BY amount DESC LIMIT {}', - 'description': 'finds opportunities', - 'template': 'find top opportunities ', - 'rank_output': True, - 'force_keys': ['Amount'], - 'exclude_keys': ['Status'], - 'show_all_keys': True + "commands": ["find top opportunities"], + "object": "opportunity", + "query": "SELECT {} FROM {} WHERE isClosed=false ORDER BY amount DESC LIMIT {}", + "description": "finds opportunities", + "template": "find top opportunities ", + "rank_output": True, + "force_keys": ["Amount"], + "exclude_keys": ["Status"], + "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"}, } @@ -109,88 +99,86 @@ 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: - self._test('test_one_result', '', help_text) + self._test("test_one_result", "", help_text) def test_one_result(self) -> None: - res = '''**[foo](https://login.salesforce.com/foo_id)** + res = """**[foo](https://login.salesforce.com/foo_id)** >**Phone**: 020 1234 5678 -''' - self._test('test_one_result', 'find contact foo', res) +""" + self._test("test_one_result", "find contact foo", res) def test_multiple_results(self) -> None: - res = '**[foo](https://login.salesforce.com/foo_id)**\n**[bar](https://login.salesforce.com/bar_id)**\n' - self._test('test_multiple_results', 'find contact foo', res) + res = "**[foo](https://login.salesforce.com/foo_id)**\n**[bar](https://login.salesforce.com/bar_id)**\n" + self._test("test_multiple_results", "find contact foo", res) def test_arg_show(self) -> None: - res = '''**[foo](https://login.salesforce.com/foo_id)** + res = """**[foo](https://login.salesforce.com/foo_id)** >**Phone**: 020 1234 5678 **[bar](https://login.salesforce.com/bar_id)** >**Phone**: 020 5678 1234 -''' - self._test('test_multiple_results', 'find contact foo -show', res) +""" + self._test("test_multiple_results", "find contact foo -show", res) def test_no_results(self) -> None: - self._test('test_no_results', 'find contact foo', 'No records found.') + self._test("test_no_results", "find contact foo", "No records found.") def test_rank_and_force_keys(self) -> None: - res = '''1) **[foo](https://login.salesforce.com/foo_id)** + res = """1) **[foo](https://login.salesforce.com/foo_id)** >**Amount**: 2 2) **[bar](https://login.salesforce.com/bar_id)** >**Amount**: 1 -''' - self._test('test_top_opportunities', 'find top opportunities 2', res) +""" + self._test("test_top_opportunities", "find top opportunities 2", res) def test_limit_arg(self) -> None: - res = '''**[foo](https://login.salesforce.com/foo_id)** + res = """**[foo](https://login.salesforce.com/foo_id)** >**Phone**: 020 1234 5678 -''' - with self.assertLogs(level='INFO') as log: - self._test('test_one_result', 'find contact foo -limit 1', res) - self.assertIn('INFO:root:Searching with limit 1', log.output) +""" + with self.assertLogs(level="INFO") as log: + self._test("test_one_result", "find contact foo -limit 1", res) + self.assertIn("INFO:root:Searching with limit 1", log.output) 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", "help", help_text) + self._test("test_one_result", "foo bar baz", help_text) + self._test("test_one_result", "find contact", "Usage: find contact [arguments]") def test_bad_auth(self) -> None: with self.assertRaises(StubBotHandler.BotQuitException): self._test_initialize(auth_success=False) def test_callback(self) -> None: - self._test('test_one_result', 'echo hello', 'hello') + self._test("test_one_result", "echo hello", "hello") def test_link_normal(self) -> None: - res = '''**[foo](https://login.salesforce.com/foo_id)** + 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) + 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) diff --git a/zulip_bots/zulip_bots/bots/salesforce/utils.py b/zulip_bots/zulip_bots/bots/salesforce/utils.py index 2861d1dc68..313fb43bfa 100644 --- a/zulip_bots/zulip_bots/bots/salesforce/utils.py +++ b/zulip_bots/zulip_bots/bots/salesforce/utils.py @@ -1,49 +1,56 @@ from typing import Any, Dict, List -link_query = 'SELECT {} FROM {} WHERE Id=\'{}\'' -default_query = 'SELECT {} FROM {} WHERE Name LIKE \'%{}%\' LIMIT {}' +link_query = "SELECT {} FROM {} WHERE Id='{}'" +default_query = "SELECT {} FROM {} WHERE Name LIKE '%{}%' LIMIT {}" 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 ' + "commands": ["search account", "find account", "search accounts", "find accounts"], + "object": "account", + "description": "Returns a list of accounts of the name specified", + "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 ' + "commands": ["search contact", "find contact", "search contacts", "find contacts"], + "object": "contact", + "description": "Returns a list of contacts of the name specified", + "template": "search contact ", }, { - 'commands': ['search opportunity', 'find opportunity', 'search opportunities', 'find opportunities'], - 'object': 'opportunity', - 'description': 'Returns a list of opportunities of the name specified', - 'template': 'search opportunity ' + "commands": [ + "search opportunity", + "find opportunity", + "search opportunities", + "find opportunities", + ], + "object": "opportunity", + "description": "Returns a list of opportunities of the name specified", + "template": "search opportunity ", }, { - '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'] - } + "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"], + }, ] # type: List[Dict[str, Any]] object_types = { - 'account': { - 'fields': 'Id, Name, Phone, BillingStreet, BillingCity, BillingState', - 'table': 'Account' + "account": { + "fields": "Id, Name, Phone, BillingStreet, BillingCity, BillingState", + "table": "Account", }, - 'contact': { - 'fields': 'Id, Name, Phone, MobilePhone, Email', - 'table': 'Contact' + "contact": {"fields": "Id, Name, Phone, MobilePhone, Email", "table": "Contact"}, + "opportunity": { + "fields": "Id, Name, Amount, Probability, StageName, CloseDate", + "table": "Opportunity", }, - 'opportunity': { - 'fields': 'Id, Name, Amount, Probability, StageName, CloseDate', - '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 9334439727..6315001b8c 100644 --- a/zulip_bots/zulip_bots/bots/stack_overflow/stack_overflow.py +++ b/zulip_bots/zulip_bots/bots/stack_overflow/stack_overflow.py @@ -1,79 +1,85 @@ -import requests import logging +from typing import Dict, Optional + +import requests -from typing import Optional, Dict 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 specific query and returns the top 3 questions from the search. It looks for messages starting with '@mention-bot' In this example, we write all Stack Overflow searches into the same stream that it was called from. - ''' + """ META = { - 'name': 'StackOverflow', - 'description': 'Searches Stack Overflow for a query and returns the top 3 articles.', + "name": "StackOverflow", + "description": "Searches Stack Overflow for a query and returns the top 3 articles.", } def usage(self) -> str: - return ''' + return """ This plugin will allow users to directly search Stack Overflow for a specific query and get the top 3 articles that are returned from the search. Users should preface query with "@mention-bot". - @mention-bot ''' + @mention-bot """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: 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]: - '''This function returns the URLs of the requested topic.''' + 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' + help_text = "Please enter your query after @mention-bot to search StackOverflow" # Checking if the link exists. - query = message['content'] - if query == '' or query == 'help': + query = message["content"] + if query == "" or query == "help": 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_url = "http://api.stackexchange.com/2.2/search/advanced" + 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.' + logging.error("broken link") + 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.' + logging.error("Page not found.") + 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' + 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:' + if len(data.json()["items"]) == 0: + 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' + 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" 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 5904a078e0..9636a052ee 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 @@ -1,5 +1,6 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests from zulip_bots.request_test_lib import mock_request_exception +from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestStackoverflowBot(BotTestCase, DefaultTests): bot_name = "stack_overflow" @@ -7,48 +8,52 @@ class TestStackoverflowBot(BotTestCase, DefaultTests): def test_bot(self) -> None: # Single-word query - bot_request = 'restful' - bot_response = ('''For search term:restful + bot_request = "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'): +""" + 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_request = "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'): +""" + 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_request = "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'): +""" + 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:" - with self.mock_http_conversation('test_incorrect_query'): + bot_request = "narendra" + 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_request = "Zulip" + 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'): + with self.mock_http_conversation("test_status_code"): self.verify_reply(bot_request, bot_response) # Request Exception - bot_request = 'Z' + bot_request = "Z" with mock_request_exception(): 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 317f8e3417..ab36122c19 100644 --- a/zulip_bots/zulip_bots/bots/susi/susi.py +++ b/zulip_bots/zulip_bots/bots/susi/susi.py @@ -1,15 +1,18 @@ -import requests from typing import Dict + +import requests + from zulip_bots.lib import BotHandler + class SusiHandler: - ''' + """ Susi AI Bot To create and know more of SUSI skills go to `https://skills.susi.ai/` - ''' + """ def usage(self) -> str: - return ''' + return """ Hi, I am Susi, people generally ask me these questions: ``` What is the exchange rate of USD to BTC @@ -33,18 +36,19 @@ def usage(self) -> str: distance between india and singapore tell me latest phone by LG ``` - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - msg = message['content'] - if msg == 'help' or msg == '': + msg = message["content"] + if msg == "help" or msg == "": bot_handler.send_reply(message, self.usage()) return reply = requests.get("https://api.susi.ai/susi/chat.json", params=dict(q=msg)) try: - answer = reply.json()['answers'][0]['actions'][0]['expression'] + answer = reply.json()["answers"][0]["actions"][0]["expression"] except Exception: 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/susi/test_susi.py b/zulip_bots/zulip_bots/bots/susi/test_susi.py index 30d62ad0e5..194a3890b9 100644 --- a/zulip_bots/zulip_bots/bots/susi/test_susi.py +++ b/zulip_bots/zulip_bots/bots/susi/test_susi.py @@ -1,13 +1,11 @@ -from zulip_bots.test_lib import ( - BotTestCase, - DefaultTests, -) +from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestSusiBot(BotTestCase, DefaultTests): bot_name = "susi" def test_help(self) -> None: - bot_response = ''' + bot_response = """ Hi, I am Susi, people generally ask me these questions: ``` What is the exchange rate of USD to BTC @@ -31,14 +29,14 @@ def test_help(self) -> None: distance between india and singapore tell me latest phone by LG ``` - ''' + """ - self.verify_reply('', bot_response) - self.verify_reply('help', bot_response) + self.verify_reply("", bot_response) + self.verify_reply("help", bot_response) def test_issue(self) -> None: - request = 'hi' - bot_response = 'Hello!' + request = "hi" + bot_response = "Hello!" - with self.mock_http_conversation('test_reply'): + with self.mock_http_conversation("test_reply"): self.verify_reply(request, bot_response) diff --git a/zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py b/zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py index 8d9f621bd5..21e004b6f9 100644 --- a/zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py +++ b/zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py @@ -1,11 +1,11 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.game_handler import GameInstance +from typing import Any, List, Tuple -from typing import List, Tuple, Any +from zulip_bots.game_handler import GameInstance +from zulip_bots.test_lib import BotTestCase, DefaultTests class TestTicTacToeBot(BotTestCase, DefaultTests): - bot_name = 'tictactoe' + bot_name = "tictactoe" # FIXME: Add tests for computer moves # FIXME: Add test lib for game_handler @@ -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]] - players = ['Human', 'Computer'] - response = 'current turn' + 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]] - players = ['Human', 'Computer'] - response = 'draw' + 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 @@ def _test_board_is_full(self, board: List[List[int]], expected_response: bool) - 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 @@ def _test_contains_winning_move(self, board: List[List[int]], expected_response: 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 @@ def test_is_valid_move(self) -> None: 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) @@ -117,7 +113,7 @@ def _test_is_valid_move(self, board: List[List[int]], move: str, expected_respon def test_player_color(self) -> None: turn = 0 - response = ':x:' + response = ":x:" self._test_player_color(turn, response) def _test_player_color(self, turn: int, expected_response: str) -> None: @@ -130,24 +126,20 @@ def test_static_responses(self) -> None: 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(model, 'current_board') is not None) - self.assertTrue(hasattr(model, 'determine_game_over') is not None) + self.assertTrue(hasattr(message_handler, "parse_board") 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: @@ -159,18 +151,19 @@ def add_user_to_cache(self, name: str, bot: Any = None) -> Any: if bot is None: bot, bot_handler = self._get_handlers() message = { - 'sender_email': '{}@example.com'.format(name), - 'sender_full_name': '{}'.format(name) + "sender_email": f"{name}@example.com", + "sender_full_name": f"{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') - bot.instances.update({'abc123': instance}) + 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" + ) + 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 840e4b17f1..0ab6298d19 100644 --- a/zulip_bots/zulip_bots/bots/tictactoe/tictactoe.py +++ b/zulip_bots/zulip_bots/bots/tictactoe/tictactoe.py @@ -1,8 +1,8 @@ import copy import random +from typing import Any, List, Tuple -from typing import List, Any, Tuple -from zulip_bots.game_handler import GameAdapter, BadMoveException +from zulip_bots.game_handler import BadMoveException, GameAdapter # ------------------------------------- @@ -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: @@ -38,13 +37,13 @@ def get_value(self, board: Any, position: Tuple[int, int]) -> int: def determine_game_over(self, players: List[str]) -> str: if self.contains_winning_move(self.current_board): - return 'current turn' + return "current turn" if self.board_is_full(self.current_board): - return 'draw' - return '' + return "draw" + 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 @@ def board_is_full(self, board: Any) -> bool: # 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 @@ def contains_winning_move(self, board: Any) -> bool: 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 @@ def get_locations_of_char(self, board: Any, char: int) -> List[List[int]]: 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 @@ def two_blanks(self, triplet: List[Tuple[int, int]], board: Any) -> List[Tuple[i 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 @@ def computer_move(self, board: Any, player_number: Any) -> Any: 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() @@ -203,74 +201,86 @@ def make_move(self, move: str, player_number: int, computer_move: bool = False) return self.computer_move(self.current_board, player_number + 1) move_coords_str = coords_from_command(move) if not self.is_valid_move(move_coords_str): - raise BadMoveException('Make sure your move is from 0-9') + raise BadMoveException("Make sure your move is from 0-9") board = self.current_board - move_coords = move_coords_str.split(',') + move_coords = move_coords_str.split(",") # Subtraction must be done to convert to the right indices, # since computers start numbering at 0. row = (int(move_coords[1])) - 1 column = (int(move_coords[0])) - 1 if board[row][column] != 0: - raise BadMoveException('Make sure your space hasn\'t already been filled.') + raise BadMoveException("Make sure your space hasn't already been filled.") board[row][column] = player_number + 1 return board class TicTacToeMessageHandler: - tokens = [':x:', ':o:'] + 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]) else: row_chars.append(self.get_player_color(e - 1)) - row_string = ' '.join(row_chars) - return row_string + '\n\n' + row_string = " ".join(row_chars) + 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. ''' - return "".join([self.parse_row(r, r_num) for r_num, r in enumerate(board)]) + """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: return self.tokens[turn] def alert_move_message(self, original_player: str, move_info: str) -> str: - move_info = move_info.replace('move ', '') - return '{} put a token at {}'.format(original_player, move_info) + move_info = move_info.replace("move ", "") + return f"{original_player} put a token at {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): - ''' + """ 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.', + "name": "TicTacToe", + "description": "Lets you play Tic-tac-toe against a computer.", } def usage(self) -> str: - return ''' + return """ You can play tic-tac-toe now! Make sure your message starts with @mention-bot. - ''' + """ def __init__(self) -> None: - game_name = 'Tic Tac Toe' - bot_name = 'tictactoe' - move_help_message = '* To move during a game, type\n`move ` or ``' - move_regex = r'(move (\d)$)|((\d)$)' + game_name = "Tic Tac Toe" + bot_name = "tictactoe" + move_help_message = "* To move during a game, type\n`move ` or ``" + move_regex = r"(move (\d)$)|((\d)$)" model = TicTacToeModel gameMessageHandler = TicTacToeMessageHandler - rules = '''Try to get three in horizontal or vertical or diagonal row to win the game.''' + rules = """Try to get three in horizontal or vertical or diagonal row to win the game.""" super().__init__( game_name, bot_name, @@ -279,17 +289,17 @@ def __init__(self) -> None: 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. ''' - cmd_num = int(cmd.replace('move ', '')) - 1 - cmd = '{},{}'.format((cmd_num % 3) + 1, (cmd_num // 3) + 1) + """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 = f"{(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 24817b6800..60af4174df 100644 --- a/zulip_bots/zulip_bots/bots/trello/test_trello.py +++ b/zulip_bots/zulip_bots/bots/trello/test_trello.py @@ -1,110 +1,113 @@ from unittest.mock import patch from zulip_bots.bots.trello.trello import TrelloHandler -from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.test_lib import StubBotHandler +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 def test_bot_responds_to_empty_message(self) -> None: - with self.mock_config_info(mock_config), patch('requests.get'): - self.verify_reply('', 'Empty Query') + with self.mock_config_info(mock_config), patch("requests.get"): + self.verify_reply("", "Empty Query") def test_bot_usage(self) -> None: - with self.mock_config_info(mock_config), patch('requests.get'): - self.verify_reply('help', ''' + with self.mock_config_info(mock_config), patch("requests.get"): + 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): - with self.mock_http_conversation('invalid_key'): + with self.mock_http_conversation("invalid_key"): TrelloHandler().initialize(StubBotHandler()) def test_invalid_command(self) -> None: - with self.mock_config_info(mock_config), patch('requests.get'): - self.verify_reply('abcd', 'Command not supported') + with self.mock_config_info(mock_config), patch("requests.get"): + 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') - - with self.mock_config_info(mock_config), patch('requests.get'): - self.verify_reply('list-commands', expected_reply) + 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) def test_get_all_boards_command(self) -> None: - with self.mock_config_info(mock_config), patch('requests.get'): - with self.mock_http_conversation('get_all_boards'): - self.verify_reply('get-all-boards', '**Boards:**\n') + with self.mock_config_info(mock_config), patch("requests.get"): + with self.mock_http_conversation("get_all_boards"): + self.verify_reply("get-all-boards", "**Boards:**\n") - with self.mock_http_conversation('get_board_descs'): + with self.mock_http_conversation("get_board_descs"): bot_instance = TrelloHandler() bot_instance.initialize(StubBotHandler()) - self.assertEqual(bot_instance.get_board_descs(['TEST']), '1.[TEST](TEST) (`TEST`)') + self.assertEqual(bot_instance.get_board_descs(["TEST"]), "1.[TEST](TEST) (`TEST`)") def test_get_all_cards_command(self) -> None: - with self.mock_config_info(mock_config), patch('requests.get'): - with self.mock_http_conversation('get_cards'): - self.verify_reply('get-all-cards TEST', '**Cards:**\n1. [TEST](TEST) (`TEST`)') + with self.mock_config_info(mock_config), patch("requests.get"): + with self.mock_http_conversation("get_cards"): + self.verify_reply("get-all-cards TEST", "**Cards:**\n1. [TEST](TEST) (`TEST`)") 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') + 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", + ) 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')) + 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"), + ) def test_command_exceptions(self) -> None: """Add appropriate tests here for all additional commands with try/except blocks. This ensures consistency.""" - expected_error_response = 'Invalid Response. Please check configuration and parameters.' + expected_error_response = "Invalid Response. Please check configuration and parameters." - with self.mock_config_info(mock_config), patch('requests.get'): - with self.mock_http_conversation('exception_boards'): - self.verify_reply('get-all-boards', expected_error_response) + with self.mock_config_info(mock_config), patch("requests.get"): + with self.mock_http_conversation("exception_boards"): + self.verify_reply("get-all-boards", expected_error_response) - with self.mock_http_conversation('exception_cards'): - self.verify_reply('get-all-cards TEST', expected_error_response) + with self.mock_http_conversation("exception_cards"): + self.verify_reply("get-all-cards TEST", expected_error_response) - with self.mock_http_conversation('exception_checklists'): - self.verify_reply('get-all-checklists TEST', expected_error_response) + with self.mock_http_conversation("exception_checklists"): + self.verify_reply("get-all-checklists TEST", expected_error_response) - with self.mock_http_conversation('exception_lists'): - self.verify_reply('get-all-lists TEST', expected_error_response) + with self.mock_http_conversation("exception_lists"): + self.verify_reply("get-all-lists TEST", expected_error_response) def test_command_invalid_arguments(self) -> None: """Add appropriate tests here for all additional commands with more than one arguments. This ensures consistency.""" - expected_error_response = 'Invalid Arguments.' + expected_error_response = "Invalid Arguments." - with self.mock_config_info(mock_config), patch('requests.get'): - self.verify_reply('get-all-cards', expected_error_response) - self.verify_reply('get-all-checklists', expected_error_response) - self.verify_reply('get-all-lists', expected_error_response) + with self.mock_config_info(mock_config), patch("requests.get"): + self.verify_reply("get-all-cards", expected_error_response) + self.verify_reply("get-all-checklists", expected_error_response) + self.verify_reply("get-all-lists", expected_error_response) diff --git a/zulip_bots/zulip_bots/bots/trello/trello.py b/zulip_bots/zulip_bots/bots/trello/trello.py index 9a6bff9a5d..effb6e44f8 100644 --- a/zulip_bots/zulip_bots/bots/trello/trello.py +++ b/zulip_bots/zulip_bots/bots/trello/trello.py @@ -1,90 +1,91 @@ -from typing import Any, List, Dict -from zulip_bots.lib import BotHandler +from typing import Any, Dict, List + import requests +from zulip_bots.lib import BotHandler + supported_commands = [ - ('help', 'Get the bot usage information.'), - ('list-commands', 'Get information about the commands supported by the bot.'), - ('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.') + ("help", "Get the bot usage information."), + ("list-commands", "Get information about the commands supported by the bot."), + ("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."), ] -INVALID_ARGUMENTS_ERROR_MESSAGE = 'Invalid Arguments.' -RESPONSE_ERROR_MESSAGE = 'Invalid Response. Please check configuration and parameters.' +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') - self.api_key = self.config_info['api_key'] - self.access_token = self.config_info['access_token'] - self.user_name = self.config_info['user_name'] + self.config_info = bot_handler.get_config_info("trello") + self.api_key = self.config_info["api_key"] + 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( + f"https://api.trello.com/1/members/{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.') + if test_query_response.text == "invalid key": + bot_handler.quit("Invalid Credentials. Please see doc.md to find out how to get them.") def usage(self) -> str: - return ''' + return """ This interactive bot can be used to interact with Trello. Use `list-commands` to get information about the supported commands. - ''' + """ def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: - content = message['content'].strip().split() + content = message["content"].strip().split() if content == []: - bot_handler.send_reply(message, 'Empty Query') + bot_handler.send_reply(message, "Empty Query") return content[0] = content[0].lower() - if content == ['help']: + if content == ["help"]: bot_handler.send_reply(message, self.usage()) return - if content == ['list-commands']: + if content == ["list-commands"]: bot_reply = self.get_all_supported_commands() - elif content == ['get-all-boards']: + elif content == ["get-all-boards"]: bot_reply = self.get_all_boards() else: - if content[0] == 'get-all-cards': + if content[0] == "get-all-cards": bot_reply = self.get_all_cards(content) - elif content[0] == 'get-all-checklists': + elif content[0] == "get-all-checklists": bot_reply = self.get_all_checklists(content) - elif content[0] == 'get-all-lists': + elif content[0] == "get-all-lists": bot_reply = self.get_all_lists(content) else: - bot_reply = 'Command not supported' + bot_reply = "Command not supported" bot_handler.send_reply(message, bot_reply) def get_all_supported_commands(self) -> str: - bot_response = '**Commands:** \n' + bot_response = "**Commands:** \n" for index, (command, desc) in enumerate(supported_commands): - bot_response += '{}. **{}**: {}\n'.format(index + 1, command, desc) + bot_response += f"{index + 1}. **{command}**: {desc}\n" return bot_response def get_all_boards(self) -> str: - get_board_ids_url = 'https://api.trello.com/1/members/{}/'.format(self.user_name) + get_board_ids_url = f"https://api.trello.com/1/members/{self.user_name}/" board_ids_response = requests.get(get_board_ids_url, params=self.auth_params) try: - boards = board_ids_response.json()['idBoards'] - bot_response = '**Boards:**\n' + self.get_board_descs(boards) + boards = board_ids_response.json()["idBoards"] + bot_response = "**Boards:**\n" + self.get_board_descs(boards) except (KeyError, ValueError, TypeError): return RESPONSE_ERROR_MESSAGE @@ -93,79 +94,90 @@ def get_all_boards(self) -> str: def get_board_descs(self, boards: List[str]) -> str: bot_response = [] # type: List[str] - get_board_desc_url = 'https://api.trello.com/1/boards/{}/' + 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) + return "\n".join(bot_response) def get_all_cards(self, content: List[str]) -> str: if len(content) != 2: return INVALID_ARGUMENTS_ERROR_MESSAGE board_id = content[1] - get_cards_url = 'https://api.trello.com/1/boards/{}/cards'.format(board_id) + get_cards_url = f"https://api.trello.com/1/boards/{board_id}/cards" cards_response = requests.get(get_cards_url, params=self.auth_params) try: cards = cards_response.json() - bot_response = ['**Cards:**'] + 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 - return '\n'.join(bot_response) + return "\n".join(bot_response) def get_all_checklists(self, content: List[str]) -> str: if len(content) != 2: return INVALID_ARGUMENTS_ERROR_MESSAGE card_id = content[1] - get_checklists_url = 'https://api.trello.com/1/cards/{}/checklists/'.format(card_id) + get_checklists_url = f"https://api.trello.com/1/cards/{card_id}/checklists/" checklists_response = requests.get(get_checklists_url, params=self.auth_params) try: checklists = checklists_response.json() - bot_response = ['**Checklists:**'] + bot_response = ["**Checklists:**"] for index, checklist in enumerate(checklists): - bot_response += ['{}. `{}`:'.format(index + 1, checklist['name'])] + bot_response += ["{}. `{}`:".format(index + 1, checklist["name"])] - if 'checkItems' in checklist: - for item in checklist['checkItems']: - bot_response += [' * [{}] {}'.format('X' if item['state'] == 'complete' else '-', item['name'])] + if "checkItems" in checklist: + for item in checklist["checkItems"]: + bot_response += [ + " * [{}] {}".format( + "X" if item["state"] == "complete" else "-", item["name"] + ) + ] except (KeyError, ValueError, TypeError): return RESPONSE_ERROR_MESSAGE - return '\n'.join(bot_response) + return "\n".join(bot_response) def get_all_lists(self, content: List[str]) -> str: if len(content) != 2: return INVALID_ARGUMENTS_ERROR_MESSAGE board_id = content[1] - get_lists_url = 'https://api.trello.com/1/boards/{}/lists'.format(board_id) + get_lists_url = f"https://api.trello.com/1/boards/{board_id}/lists" lists_response = requests.get(get_lists_url, params=self.auth_params) try: lists = lists_response.json() - bot_response = ['**Lists:**'] + bot_response = ["**Lists:**"] for index, _list in enumerate(lists): - bot_response += ['{}. {}'.format(index + 1, _list['name'])] + bot_response += ["{}. {}".format(index + 1, _list["name"])] - if 'cards' in _list: - for card in _list['cards']: - bot_response += [' * {}'.format(card['name'])] + if "cards" in _list: + for card in _list["cards"]: + bot_response += [" * {}".format(card["name"])] except (KeyError, ValueError, TypeError): return RESPONSE_ERROR_MESSAGE - return '\n'.join(bot_response) + 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 525afd4533..4730a3f3e1 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 @@ -1,42 +1,35 @@ -import json import html - +import json +from typing import Any, Dict, Optional, Tuple from unittest.mock import patch -from typing import Optional, Tuple, Any, Dict - -from zulip_bots.test_lib import ( - BotTestCase, - DefaultTests, - read_bot_fixture_data, - StubBotHandler, -) - -from zulip_bots.request_test_lib import ( - mock_request_exception, -) from zulip_bots.bots.trivia_quiz.trivia_quiz import ( - get_quiz_from_payload, fix_quotes, get_quiz_from_id, - update_quiz, + get_quiz_from_payload, handle_answer, + update_quiz, ) +from zulip_bots.request_test_lib import mock_request_exception +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, read_bot_fixture_data + 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() - quiz_payload = read_bot_fixture_data('trivia_quiz', 'test_new_question')['response'] - with patch('random.shuffle'): + quiz_payload = read_bot_fixture_data("trivia_quiz", "test_new_question")["response"] + with patch("random.shuffle"): quiz = get_quiz_from_payload(quiz_payload) return quiz, bot_handler @@ -48,110 +41,119 @@ def _test(self, message: str, response: str, fixture: Optional[str] = None) -> N self.verify_reply(message, response) def test_bot_responds_to_empty_message(self) -> None: - self._test('', 'type "new" for a new question') + self._test("", 'type "new" for a new question') def test_bot_new_question(self) -> None: - with patch('random.shuffle'): - self._test('new', self.new_question_response, 'test_new_question') + with patch("random.shuffle"): + self._test("new", self.new_question_response, "test_new_question") def test_question_not_available(self) -> None: - self._test('new', 'Uh-Oh! Trivia service is down.', 'test_status_code') + self._test("new", "Uh-Oh! Trivia service is down.", "test_status_code") with mock_request_exception(): - self.verify_reply('new', 'Uh-Oh! Trivia service is down.') + self.verify_reply("new", "Uh-Oh! Trivia service is down.") def test_fix_quotes(self) -> None: - self.assertEqual(fix_quotes('test & test'), html.unescape('test & test')) - print('f') - with patch('html.unescape') as mock_html_unescape: + self.assertEqual(fix_quotes("test & test"), html.unescape("test & test")) + print("f") + with patch("html.unescape") as mock_html_unescape: 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.") + fix_quotes("test") + 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') + self._test(reply, "Invalid answer format") def test_invalid_quiz_id(self) -> None: - self._test('answer Q100 A', 'Invalid quiz id') + self._test("answer Q100 A", "Invalid quiz id") def test_answers(self) -> None: - quiz_payload = read_bot_fixture_data('trivia_quiz', 'test_new_question')['response'] - with patch('random.shuffle'): + quiz_payload = read_bot_fixture_data("trivia_quiz", "test_new_question")["response"] + with patch("random.shuffle"): quiz = get_quiz_from_payload(quiz_payload) # Test initial storage - self.assertEqual(quiz['question'], 'Which class of animals are newts members of?') - self.assertEqual(quiz['correct_letter'], 'A') - self.assertEqual(quiz['answers']['D'], 'Mammals') - self.assertEqual(quiz['answered_options'], []) - self.assertEqual(quiz['pending'], True) + self.assertEqual(quiz["question"], "Which class of animals are newts members of?") + self.assertEqual(quiz["correct_letter"], "A") + self.assertEqual(quiz["answers"]["D"], "Mammals") + self.assertEqual(quiz["answered_options"], []) + 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)): - self._test('answer Q001 B', ':disappointed: WRONG, Foo Test User! B is not correct.') + 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.start_new_quiz'): - self._test('answer Q001 A', ':tada: **Amphibian** is correct, Foo Test User!') + 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!") def test_update_quiz(self) -> None: quiz, bot_handler = self.get_test_quiz() - update_quiz(quiz, 'Q001', bot_handler) - test_quiz = json.loads(bot_handler.storage.get('Q001')) + update_quiz(quiz, "Q001", bot_handler) + test_quiz = json.loads(bot_handler.storage.get("Q001")) self.assertEqual(test_quiz, quiz) def test_get_quiz_from_id(self) -> None: quiz, bot_handler = self.get_test_quiz() - bot_handler.storage.put('Q001', quiz) - self.assertEqual(get_quiz_from_id('Q001', bot_handler), quiz) + bot_handler.storage.put("Q001", quiz) + self.assertEqual(get_quiz_from_id("Q001", bot_handler), quiz) def test_handle_answer(self) -> None: quiz, bot_handler = self.get_test_quiz() # create test initial storage - update_quiz(quiz, 'Q001', bot_handler) + update_quiz(quiz, "Q001", bot_handler) # test for a correct answer - start_new_question, response = handle_answer(quiz, 'A', 'Q001', bot_handler, 'Test user') + start_new_question, response = handle_answer(quiz, "A", "Q001", bot_handler, "Test user") self.assertTrue(start_new_question) - self.assertEqual(response, ':tada: **Amphibian** is correct, Test user!') + self.assertEqual(response, ":tada: **Amphibian** is correct, Test user!") # test for an incorrect answer - start_new_question, response = handle_answer(quiz, 'D', 'Q001', bot_handler, 'Test User') + start_new_question, response = handle_answer(quiz, "D", "Q001", bot_handler, "Test User") self.assertFalse(start_new_question) - self.assertEqual(response, ':disappointed: WRONG, Test User! D is not correct.') + self.assertEqual(response, ":disappointed: WRONG, Test User! D is not correct.") def test_handle_answer_three_failed_attempts(self) -> None: quiz, bot_handler = self.get_test_quiz() # create test storage for a question which has been incorrectly answered twice - quiz['answered_options'] = ['C', 'B'] - update_quiz(quiz, 'Q001', bot_handler) + quiz["answered_options"] = ["C", "B"] + update_quiz(quiz, "Q001", bot_handler) # 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**.') + 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.assertTrue(start_new_question) - quiz_reset = json.loads(bot_handler.storage.get('Q001')) - self.assertEqual(quiz_reset['pending'], False) + quiz_reset = json.loads(bot_handler.storage.get("Q001")) + self.assertEqual(quiz_reset["pending"], False) # test response after question has ended - incorrect_answers = ['B', 'C', 'D'] + 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!') + start_new_question, response = handle_answer(quiz, "A", "Q001", bot_handler, "Test User") + self.assertEqual(response, ":tada: **Amphibian** is correct, Test User!") self.assertFalse(start_new_question) # test storage after question has ended - quiz_reset = json.loads(bot_handler.storage.get('Q001')) - self.assertEqual(quiz_reset['pending'], False) + quiz_reset = json.loads(bot_handler.storage.get("Q001")) + self.assertEqual(quiz_reset["pending"], False) 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 d78edb896c..ac444f16bb 100644 --- a/zulip_bots/zulip_bots/bots/trivia_quiz/trivia_quiz.py +++ b/zulip_bots/zulip_bots/bots/trivia_quiz/trivia_quiz.py @@ -1,49 +1,55 @@ import html import json -import requests import random import re -from typing import Optional, Any, Dict, Tuple +from typing import Any, Dict, Optional, Tuple + +import requests + from zulip_bots.lib import BotHandler + class NotAvailableException(Exception): pass + class InvalidAnswerException(Exception): pass + class TriviaQuizHandler: def usage(self) -> str: - return ''' + return """ This plugin will give users a trivia question from - the open trivia database at opentdb.com.''' + the open trivia database at opentdb.com.""" def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: - query = message['content'] - if query == 'new': + query = message["content"] + if query == "new": try: start_new_quiz(message, bot_handler) return except NotAvailableException: - bot_response = 'Uh-Oh! Trivia service is down.' + bot_response = "Uh-Oh! Trivia service is down." bot_handler.send_reply(message, bot_response) return - elif query.startswith('answer'): + elif query.startswith("answer"): try: (quiz_id, answer) = parse_answer(query) except InvalidAnswerException: - bot_response = 'Invalid answer format' + bot_response = "Invalid answer format" bot_handler.send_reply(message, bot_response) return try: quiz_payload = get_quiz_from_id(quiz_id, bot_handler) except (KeyError, TypeError): - bot_response = 'Invalid quiz id' + bot_response = "Invalid quiz id" 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) @@ -52,9 +58,11 @@ def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> No 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) @@ -63,26 +71,29 @@ 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) + m = re.match(r"answer\s+(Q...)\s+(.)", query) if not m: raise InvalidAnswerException() quiz_id = m.group(1) answer = m.group(2).upper() - if answer not in 'ABCD': + if answer not in "ABCD": raise InvalidAnswerException() 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' + url = "https://opentdb.com/api.php?amount=1&type=multiple" try: data = requests.get(url) @@ -96,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 :) @@ -105,23 +117,20 @@ def fix_quotes(s: str) -> Optional[str]: try: return html.unescape(s) except Exception: - raise Exception('Please use python3.4 or later for this bot.') + 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'] - letters = ['A', 'B', 'C', 'D'] + result = payload["results"][0] + question = result["question"] + letters = ["A", "B", "C", "D"] random.shuffle(letters) correct_letter = letters[0] answers = dict() - answers[correct_letter] = result['correct_answer'] + 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, @@ -131,39 +140,41 @@ 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') + quiz_num = storage.get("quiz_id") except (KeyError, TypeError): quiz_num = 0 quiz_num += 1 quiz_num = quiz_num % (1000) - storage.put('quiz_id', quiz_num) - quiz_id = 'Q%03d' % (quiz_num,) + storage.put("quiz_id", quiz_num) + 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'] - answers = quiz['answers'] + widget_type = "zform" + question = quiz["question"] + answers = quiz["answers"] - heading = quiz_id + ': ' + question + heading = quiz_id + ": " + question def get_choice(letter: str) -> Dict[str, str]: answer = answers[letter] - reply = 'answer ' + quiz_id + ' ' + letter + reply = "answer " + quiz_id + " " + letter return dict( - type='multiple_choice', + type="multiple_choice", short_name=letter, long_name=answer, reply=reply, ) - choices = [get_choice(letter) for letter in 'ABCD'] + choices = [get_choice(letter) for letter in "ABCD"] extra_data = dict( - type='choices', + type="choices", heading=heading, choices=choices, ) @@ -175,61 +186,67 @@ def get_choice(letter: str) -> Dict[str, 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( + question = quiz["question"] + answers = quiz["answers"] + 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) + for letter in "ABCD" + ) + how_to_respond = f"""**reply**: answer {quiz_id} """ - content = ''' + content = """ Q: {question} {answer_list} -{how_to_respond}'''.format( +{how_to_respond}""".format( question=question, answer_list=answer_list, how_to_respond=how_to_respond, ) 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}!' + response = ":tada: **{answer}** is correct, {sender_name}!" else: if num_answers >= 3: - response = ':disappointed: WRONG, {sender_name}! The correct answer is **{answer}**.' + response = ":disappointed: WRONG, {sender_name}! The correct answer is **{answer}**." else: - response = ':disappointed: WRONG, {sender_name}! {option} is not correct.' + 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]: - answer = quiz['answers'][quiz['correct_letter']] - is_new_answer = (option not in quiz['answered_options']) + +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"] if is_new_answer: - quiz['answered_options'].append(option) + quiz["answered_options"].append(option) - num_answers = len(quiz['answered_options']) - is_correct = (option == quiz['correct_letter']) + num_answers = len(quiz["answered_options"]) + is_correct = option == quiz["correct_letter"] - start_new_question = quiz['pending'] and (is_correct or num_answers >= 3) + start_new_question = quiz["pending"] and (is_correct or num_answers >= 3) if start_new_question or is_correct: - quiz['pending'] = False + quiz["pending"] = False if is_new_answer or start_new_question: 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 fe91cf7b90..89a0d68e5c 100644 --- a/zulip_bots/zulip_bots/bots/twitpost/test_twitpost.py +++ b/zulip_bots/zulip_bots/bots/twitpost/test_twitpost.py @@ -1,21 +1,18 @@ -from zulip_bots.test_lib import ( - StubBotHandler, - BotTestCase, - DefaultTests, - get_bot_message_handler, -) -from zulip_bots.test_file_utils import ( - read_bot_fixture_data, -) from unittest.mock import patch +from zulip_bots.test_file_utils import read_bot_fixture_data +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_bot_message_handler + + class TestTwitpostBot(BotTestCase, DefaultTests): bot_name = "twitpost" - 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') + 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: bot = get_bot_message_handler(self.bot_name) @@ -24,24 +21,26 @@ def test_bot_usage(self) -> None: with self.mock_config_info(self.mock_config): bot.initialize(bot_handler) - self.assertIn('This bot posts on twitter from zulip chat itself', bot.usage()) + self.assertIn("This bot posts on twitter from zulip chat itself", bot.usage()) def test_bot_responds_to_empty_message(self) -> None: with self.mock_config_info(self.mock_config): - self.verify_reply('', 'Please check help for usage.') + self.verify_reply("", "Please check help for usage.") 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") - - @patch('tweepy.API.update_status', return_value=api_response) + 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): - test_message = 'tweet Maybe he\'ll finally find his keys. #peterfalk' - bot_response = 'Tweet Posted\nhttps://twitter.com/jasoncosta/status/243145735212777472' + test_message = "tweet Maybe he'll finally find his keys. #peterfalk" + bot_response = "Tweet Posted\nhttps://twitter.com/jasoncosta/status/243145735212777472" with self.mock_config_info(self.mock_config): self.verify_reply(test_message, bot_response) diff --git a/zulip_bots/zulip_bots/bots/twitpost/twitpost.py b/zulip_bots/zulip_bots/bots/twitpost/twitpost.py index fbbb16bd46..ad6b258461 100644 --- a/zulip_bots/zulip_bots/bots/twitpost/twitpost.py +++ b/zulip_bots/zulip_bots/bots/twitpost/twitpost.py @@ -1,37 +1,44 @@ -import tweepy from typing import Dict + +import tweepy + from zulip_bots.lib import BotHandler -class TwitpostBot: +class TwitpostBot: def usage(self) -> str: - return ''' This bot posts on twitter from zulip chat itself. + 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" + 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" + ) 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']) + 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"] + ) self.api = tweepy.API(auth, parser=tweepy.parsers.JSONParser()) def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: content = message["content"] - if content.strip() == '': - bot_handler.send_reply(message, 'Please check help for usage.') + if content.strip() == "": + bot_handler.send_reply(message, "Please check help for usage.") return - if content.strip() == 'help': + if content.strip() == "help": bot_handler.send_reply(message, self.help_content) return @@ -41,8 +48,7 @@ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> No 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 = f"https://twitter.com/{screen_name}/status/{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 1012d3067a..7b3c0e1d0e 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 @@ -1,47 +1,51 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests -from zulip_bots.bots.virtual_fs.virtual_fs import sample_conversation from unittest.mock import patch +from zulip_bots.bots.virtual_fs.virtual_fs import sample_conversation +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 = [ ("mkdir home", "foo@example.com:\ndirectory created"), ] message = dict( - display_recipient=[{'email': 'foo@example.com'}, {'email': 'boo@example.com'}], - sender_email='foo@example.com', - sender_full_name='Foo Test User', - sender_id='123', + display_recipient=[{"email": "foo@example.com"}, {"email": "boo@example.com"}], + sender_email="foo@example.com", + sender_full_name="Foo Test User", + sender_id="123", content="mkdir home", ) - with patch('zulip_bots.test_lib.BotTestCase.make_request_message', return_value=message): + with patch("zulip_bots.test_lib.BotTestCase.make_request_message", return_value=message): self.verify_dialog(expected) def test_sample_conversation_help(self) -> None: # There's nothing terribly tricky about the "sample conversation," # so we just do a quick sanity check. - reply = self.get_reply_dict('sample_conversation') - content = reply['content'] - frag = 'foo@example.com:\ncd /\nCurrent path: /\n\n' + reply = self.get_reply_dict("sample_conversation") + content = reply["content"] + frag = "foo@example.com:\ncd /\nCurrent path: /\n\n" self.assertTrue(content.startswith(frag)) - frag = 'read home/stuff/file1\nERROR: file does not exist\n\n' + frag = "read home/stuff/file1\nERROR: file does not exist\n\n" self.assertIn(frag, content) def test_sample_conversation(self) -> None: @@ -50,9 +54,7 @@ def test_sample_conversation(self) -> None: # 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 4bbccd4838..a514e3101c 100644 --- a/zulip_bots/zulip_bots/bots/virtual_fs/virtual_fs.py +++ b/zulip_bots/zulip_bots/bots/virtual_fs/virtual_fs.py @@ -1,39 +1,40 @@ # See readme.md for instructions on running this code. -import re import os - +import re from typing import Any, Dict, List, Set, Tuple, Union + from zulip_bots.lib import BotHandler + class VirtualFsHandler: META = { - 'name': 'VirtualFs', - 'description': 'Provides a simple, permanent file system to store and retrieve strings.', + "name": "VirtualFs", + "description": "Provides a simple, permanent file system to store and retrieve strings.", } def usage(self) -> str: return get_help() def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: - command = message['content'] + command = message["content"] if command == "": command = "help" - sender = message['sender_email'] + sender = message["sender_email"] - recipient = message['display_recipient'] + recipient = message["display_recipient"] if isinstance(recipient, list): # If not a stream, then hash on list of emails - recipient = " ".join([x['email'] for x in recipient]) + recipient = " ".join(x["email"] for x in recipient) storage = bot_handler.storage if not storage.contains(recipient): storage.put(recipient, fs_new()) fs = storage.get(recipient) - if sender not in fs['user_paths']: - fs['user_paths'][sender] = '/' + if sender not in fs["user_paths"]: + fs["user_paths"][sender] = "/" fs, msg = fs_command(fs, sender, command) - prependix = '{}:\n'.format(sender) + prependix = f"{sender}:\n" msg = prependix + msg storage.put(recipient, fs) @@ -41,7 +42,7 @@ def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> No def get_help() -> str: - return ''' + return """ This bot implements a virtual file system for a stream. The locations of text are persisted for the lifetime of the bot running, and if you rename a stream, you will lose the info. @@ -60,287 +61,302 @@ def get_help() -> str: ``` Use commands like `@mention-bot help write` for more details on specific commands. -''' +""" + def sample_conversation() -> List[Tuple[str, str]]: return [ - ('cd /', 'Current path: /'), - ('cd /home', 'ERROR: invalid path'), - ('cd .', 'ERROR: invalid path'), - ('mkdir home', 'directory created'), - ('cd home', 'Current path: /home/'), - ('cd /home/', 'Current path: /home/'), - ('mkdir stuff/', 'ERROR: stuff/ is not a valid name'), - ('mkdir stuff', 'directory created'), - ('write stuff/file1 something', 'file written'), - ('read stuff/file1', 'something'), - ('read /home/stuff/file1', 'something'), - ('read home/stuff/file1', 'ERROR: file does not exist'), - ('pwd ', '/home/'), - ('pwd bla', 'ERROR: syntax: pwd'), - ('ls bla foo', 'ERROR: syntax: ls '), - ('cd /', 'Current path: /'), - ('rm home', 'ERROR: /home/ is a directory, file required'), - ('rmdir home', 'removed'), - ('ls ', 'WARNING: directory is empty'), - ('cd home', 'ERROR: invalid path'), - ('read /home/stuff/file1', 'ERROR: file does not exist'), - ('cd /', 'Current path: /'), - ('write /foo contents of /foo', 'file written'), - ('read /foo', 'contents of /foo'), - ('write /bar Contents: bar bar', 'file written'), - ('read /bar', 'Contents: bar bar'), - ('write /bar invalid', 'ERROR: file already exists'), - ('rm /bar', 'removed'), - ('rm /bar', 'ERROR: file does not exist'), - ('write /bar new bar', 'file written'), - ('read /bar', 'new bar'), - ('write /yo/invalid whatever', 'ERROR: /yo is not a directory'), - ('mkdir /yo', 'directory created'), - ('read /yo', 'ERROR: /yo/ is a directory, file required'), - ('ls /yo', 'WARNING: directory is empty'), - ('read /yo/nada', 'ERROR: file does not exist'), - ('write /yo whatever', 'ERROR: file already exists'), - ('write /yo/apple red', 'file written'), - ('read /yo/apple', 'red'), - ('mkdir /yo/apple', 'ERROR: file already exists'), - ('ls /invalid', 'ERROR: file does not exist'), - ('ls /foo', 'ERROR: /foo is not a directory'), - ('ls /', '* /*bar*\n* /*foo*\n* /yo/'), - ('invalid command', 'ERROR: unrecognized command'), - ('write', 'ERROR: syntax: write '), + ("cd /", "Current path: /"), + ("cd /home", "ERROR: invalid path"), + ("cd .", "ERROR: invalid path"), + ("mkdir home", "directory created"), + ("cd home", "Current path: /home/"), + ("cd /home/", "Current path: /home/"), + ("mkdir stuff/", "ERROR: stuff/ is not a valid name"), + ("mkdir stuff", "directory created"), + ("write stuff/file1 something", "file written"), + ("read stuff/file1", "something"), + ("read /home/stuff/file1", "something"), + ("read home/stuff/file1", "ERROR: file does not exist"), + ("pwd ", "/home/"), + ("pwd bla", "ERROR: syntax: pwd"), + ("ls bla foo", "ERROR: syntax: ls "), + ("cd /", "Current path: /"), + ("rm home", "ERROR: /home/ is a directory, file required"), + ("rmdir home", "removed"), + ("ls ", "WARNING: directory is empty"), + ("cd home", "ERROR: invalid path"), + ("read /home/stuff/file1", "ERROR: file does not exist"), + ("cd /", "Current path: /"), + ("write /foo contents of /foo", "file written"), + ("read /foo", "contents of /foo"), + ("write /bar Contents: bar bar", "file written"), + ("read /bar", "Contents: bar bar"), + ("write /bar invalid", "ERROR: file already exists"), + ("rm /bar", "removed"), + ("rm /bar", "ERROR: file does not exist"), + ("write /bar new bar", "file written"), + ("read /bar", "new bar"), + ("write /yo/invalid whatever", "ERROR: /yo is not a directory"), + ("mkdir /yo", "directory created"), + ("read /yo", "ERROR: /yo/ is a directory, file required"), + ("ls /yo", "WARNING: directory is empty"), + ("read /yo/nada", "ERROR: file does not exist"), + ("write /yo whatever", "ERROR: file already exists"), + ("write /yo/apple red", "file written"), + ("read /yo/apple", "red"), + ("mkdir /yo/apple", "ERROR: file already exists"), + ("ls /invalid", "ERROR: file does not exist"), + ("ls /foo", "ERROR: /foo is not a directory"), + ("ls /", "* /*bar*\n* /*foo*\n* /yo/"), + ("invalid command", "ERROR: unrecognized command"), + ("write", "ERROR: syntax: write "), ] + REGEXES = dict( - command='(cd|ls|mkdir|read|rmdir|rm|write|pwd)', - path=r'(\S+)', - optional_path=r'(\S*)', - some_text='(.+)', + command="(cd|ls|mkdir|read|rmdir|rm|write|pwd)", + path=r"(\S+)", + optional_path=r"(\S*)", + some_text="(.+)", ) + def get_commands() -> Dict[str, Tuple[Any, List[str]]]: return { - 'help': (fs_help, ['command']), - 'ls': (fs_ls, ['optional_path']), - 'mkdir': (fs_mkdir, ['path']), - 'read': (fs_read, ['path']), - 'rm': (fs_rm, ['path']), - 'rmdir': (fs_rmdir, ['path']), - 'write': (fs_write, ['path', 'some_text']), - 'cd': (fs_cd, ['path']), - 'pwd': (fs_pwd, []), + "help": (fs_help, ["command"]), + "ls": (fs_ls, ["optional_path"]), + "mkdir": (fs_mkdir, ["path"]), + "read": (fs_read, ["path"]), + "rm": (fs_rm, ["path"]), + "rmdir": (fs_rmdir, ["path"]), + "write": (fs_write, ["path", "some_text"]), + "cd": (fs_cd, ["path"]), + "pwd": (fs_pwd, []), } + def fs_command(fs: str, user: str, cmd: str) -> Tuple[str, Any]: cmd = cmd.strip() - if cmd == 'help': + if cmd == "help": return fs, get_help() - if cmd == 'sample_conversation': - sample = '\n\n'.join( - '\n'.join(tup) - for tup - in sample_conversation() - ) + if cmd == "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' + return fs, "ERROR: unrecognized command" f, arg_names = commands[cmd_name] partial_regexes = [REGEXES[a] for a in arg_names] - regex = ' '.join(partial_regexes) - regex += '$' + regex = " ".join(partial_regexes) + regex += "$" m = re.match(regex, cmd_args) if m: return f(fs, user, *m.groups()) - elif cmd_name == 'help': + elif cmd_name == "help": return fs, get_help() else: - return fs, 'ERROR: ' + syntax_help(cmd_name) + return fs, "ERROR: " + syntax_help(cmd_name) + def syntax_help(cmd_name: str) -> str: commands = get_commands() f, arg_names = commands[cmd_name] - arg_syntax = ' '.join('<' + a + '>' for a in arg_names) + arg_syntax = " ".join("<" + a + ">" for a in arg_names) if arg_syntax: - cmd = cmd_name + ' ' + arg_syntax + cmd = cmd_name + " " + arg_syntax else: cmd = cmd_name - return 'syntax: {}'.format(cmd) + return f"syntax: {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: return fs, msg if path in fs: - return fs, 'ERROR: file already exists' + return fs, "ERROR: file already exists" dir_path = os.path.dirname(path) if not is_directory(fs, dir_path): - msg = 'ERROR: {} is not a directory'.format(dir_path) + msg = f"ERROR: {dir_path} is not a directory" return fs, msg new_fs = fs.copy() - new_dir = directory({path}.union(fs[dir_path]['fns'])) + new_dir = directory({path}.union(fs[dir_path]["fns"])) new_fs[dir_path] = new_dir new_fs[path] = directory([]) - msg = 'directory created' + 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] + if fn == "." or fn == "": + path = fs["user_paths"][user] else: path, msg = make_path(fs, user, fn) if msg: return fs, msg if path not in fs: - msg = 'ERROR: file does not exist' + msg = "ERROR: file does not exist" return fs, msg if not is_directory(fs, path): - return fs, 'ERROR: {} is not a directory'.format(path) - fns = fs[path]['fns'] + return fs, f"ERROR: {path} is not a directory" + fns = fs[path]["fns"] if not fns: - return fs, 'WARNING: directory is empty' - msg = '\n'.join('* ' + nice_path(fs, path) for path in sorted(fns)) + return fs, "WARNING: directory is empty" + 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] + 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: return fs, msg if path not in fs: - msg = 'ERROR: file does not exist' + msg = "ERROR: file does not exist" return fs, msg - if fs[path]['kind'] == 'dir': - msg = 'ERROR: {} is a directory, file required'.format(nice_path(fs, path)) + if fs[path]["kind"] == "dir": + msg = f"ERROR: {nice_path(fs, path)} is a directory, file required" return fs, msg new_fs = fs.copy() new_fs.pop(path) directory = get_directory(path) - new_fs[directory]['fns'].remove(path) - msg = 'removed' + new_fs[directory]["fns"].remove(path) + 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: return fs, msg if path not in fs: - msg = 'ERROR: directory does not exist' + msg = "ERROR: directory does not exist" return fs, msg - if fs[path]['kind'] == 'text': - msg = 'ERROR: {} is a file, directory required'.format(nice_path(fs, path)) + if fs[path]["kind"] == "text": + msg = f"ERROR: {nice_path(fs, path)} is a file, directory required" return fs, msg new_fs = fs.copy() new_fs.pop(path) directory = get_directory(path) - new_fs[directory]['fns'].remove(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' + 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: return fs, msg if path in fs: - msg = 'ERROR: file already exists' + msg = "ERROR: file already exists" return fs, msg dir_path = os.path.dirname(path) if not is_directory(fs, dir_path): - msg = 'ERROR: {} is not a directory'.format(dir_path) + msg = f"ERROR: {dir_path} is not a directory" return fs, msg new_fs = fs.copy() - new_dir = directory({path}.union(fs[dir_path]['fns'])) + new_dir = directory({path}.union(fs[dir_path]["fns"])) new_fs[dir_path] = new_dir new_fs[path] = text_file(content) - msg = 'file written' + 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: return fs, msg if path not in fs: - msg = 'ERROR: file does not exist' + msg = "ERROR: file does not exist" return fs, msg - if fs[path]['kind'] == 'dir': - msg = 'ERROR: {} is a directory, file required'.format(nice_path(fs, path)) + if fs[path]["kind"] == "dir": + msg = f"ERROR: {nice_path(fs, path)} is a directory, file required" return fs, msg - val = fs[path]['content'] + 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] == '/': + if len(fn) > 1 and fn[-1] == "/": fn = fn[:-1] - path = fn if len(fn) > 0 and fn[0] == '/' else make_path(fs, user, fn)[0] + path = fn if len(fn) > 0 and fn[0] == "/" else make_path(fs, user, fn)[0] if path not in fs: - msg = 'ERROR: invalid path' + msg = "ERROR: invalid path" return fs, msg - if fs[path]['kind'] == 'text': - msg = 'ERROR: {} is a file, directory required'.format(nice_path(fs, path)) + if fs[path]["kind"] == "text": + msg = f"ERROR: {nice_path(fs, path)} is a file, directory required" return fs, msg - fs['user_paths'][user] = path - return fs, "Current path: {}".format(nice_path(fs, path)) + fs["user_paths"][user] = path + return fs, f"Current path: {nice_path(fs, path)}" + def make_path(fs: Dict[str, Any], user: str, leaf: str) -> List[str]: - if leaf == '/': - return ['/', ''] - if leaf.endswith('/'): - return ['', 'ERROR: {} is not a valid name'.format(leaf)] - if leaf.startswith('/'): - return [leaf, ''] - path = fs['user_paths'][user] - if not path.endswith('/'): - path += '/' + if leaf == "/": + return ["/", ""] + if leaf.endswith("/"): + return ["", f"ERROR: {leaf} is not a valid name"] + if leaf.startswith("/"): + return [leaf, ""] + path = fs["user_paths"][user] + if not path.endswith("/"): + path += "/" path += leaf - return [path, ''] + return [path, ""] + def nice_path(fs: Dict[str, Any], path: str) -> str: path_nice = path - slash = path.rfind('/') + 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:]) - elif path != '/': - path_nice = '{}/'.format(path) + return "ERROR: the current directory does not exist" + if fs[path]["kind"] == "text": + path_nice = f"{path[: slash + 1]}*{path[slash + 1 :]}*" + elif path != "/": + path_nice = f"{path}/" return path_nice + def get_directory(path: str) -> str: - slash = path.rfind('/') + slash = path.rfind("/") if slash == 0: - return '/' + return "/" 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)) + return dict(kind="dir", fns=list(fns)) + def text_file(content: str) -> Dict[str, str]: - return dict(kind='text', content=content) + 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' + return fs[fn]["kind"] == "dir" + handler_class = VirtualFsHandler diff --git a/zulip_bots/zulip_bots/bots/weather/test_weather.py b/zulip_bots/zulip_bots/bots/weather/test_weather.py index aa0e6958fb..f0e822e086 100644 --- a/zulip_bots/zulip_bots/bots/weather/test_weather.py +++ b/zulip_bots/zulip_bots/bots/weather/test_weather.py @@ -1,12 +1,13 @@ +from typing import Optional from unittest.mock import patch + from zulip_bots.test_lib import BotTestCase, DefaultTests -from typing import Optional class TestWeatherBot(BotTestCase, DefaultTests): bot_name = "weather" - help_content = ''' + help_content = """ This bot returns weather info for specified city. You specify city in the following format: city, state/country @@ -14,10 +15,10 @@ class TestWeatherBot(BotTestCase, DefaultTests): For example: @**Weather Bot** Portland @**Weather Bot** Portland, Me - '''.strip() + """.strip() def _test(self, message: str, response: str, fixture: Optional[str] = None) -> None: - with self.mock_config_info({'key': '123456'}): + with self.mock_config_info({"key": "123456"}): if fixture: with self.mock_http_conversation(fixture): self.verify_reply(message, response) @@ -26,27 +27,27 @@ def _test(self, message: str, response: str, fixture: Optional[str] = None) -> N # Override default function in BotTestCase def test_bot_responds_to_empty_message(self) -> None: - with patch('requests.get'): - self._test('', self.help_content) + with patch("requests.get"): + self._test("", self.help_content) def test_bot(self) -> None: # City query bot_response = "Weather in New York, US:\n71.33 F / 21.85 C\nMist" - self._test('New York', bot_response, 'test_only_city') + self._test("New York", bot_response, "test_only_city") # City with country query bot_response = "Weather in New Delhi, IN:\n80.33 F / 26.85 C\nMist" - self._test('New Delhi, India', bot_response, 'test_city_with_country') + self._test("New Delhi, India", bot_response, "test_city_with_country") # Only country query: returns the weather of the capital city bot_response = "Weather in London, GB:\n58.73 F / 14.85 C\nShower Rain" - self._test('United Kingdom', bot_response, 'test_only_country') + self._test("United Kingdom", bot_response, "test_only_country") # City not found query bot_response = "Sorry, city not found" - self._test('fghjklasdfgh', bot_response, 'test_city_not_found') + self._test("fghjklasdfgh", bot_response, "test_city_not_found") # help message - with patch('requests.get'): - self._test('help', self.help_content) + with patch("requests.get"): + self._test("help", self.help_content) diff --git a/zulip_bots/zulip_bots/bots/weather/weather.py b/zulip_bots/zulip_bots/bots/weather/weather.py index c2d5dad4fa..46540fac73 100644 --- a/zulip_bots/zulip_bots/bots/weather/weather.py +++ b/zulip_bots/zulip_bots/bots/weather/weather.py @@ -1,34 +1,36 @@ # See readme.md for instructions on running this code. +from typing import Any, Dict + import requests -from typing import Any, Dict from zulip_bots.lib import BotHandler -api_url = 'http://api.openweathermap.org/data/2.5/weather' +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'] - self.response_pattern = 'Weather in {}, {}:\n{:.2f} F / {:.2f} C\n{}' + self.api_key = bot_handler.get_config_info("weather")["key"] + self.response_pattern = "Weather in {}, {}:\n{:.2f} F / {:.2f} C\n{}" self.check_api_key(bot_handler) def check_api_key(self, bot_handler: BotHandler) -> None: - api_params = dict(q='nyc', APPID=self.api_key) + api_params = dict(q="nyc", APPID=self.api_key) test_response = requests.get(api_url, params=api_params) try: test_response_data = test_response.json() - if test_response_data['cod'] == 401: - bot_handler.quit('API Key not valid. Please see doc.md to find out how to get it.') + if test_response_data["cod"] == 401: + bot_handler.quit("API Key not valid. Please see doc.md to find out how to get it.") except KeyError: pass def usage(self) -> str: - return ''' + return """ This plugin will give info about weather in a specified city - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - help_content = ''' + help_content = """ This bot returns weather info for specified city. You specify city in the following format: city, state/country @@ -36,28 +38,28 @@ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> No For example: @**Weather Bot** Portland @**Weather Bot** Portland, Me - '''.strip() + """.strip() - if (message['content'] == 'help') or (message['content'] == ''): + if (message["content"] == "help") or (message["content"] == ""): response = help_content else: - api_params = dict(q=message['content'], APPID=self.api_key) + api_params = dict(q=message["content"], APPID=self.api_key) r = requests.get(api_url, params=api_params) - if r.json()['cod'] == "404": + if r.json()["cod"] == "404": response = "Sorry, city not found" else: - response = format_response(r, message['content'], self.response_pattern) + response = format_response(r, message["content"], self.response_pattern) bot_handler.send_reply(message, response) def format_response(text: Any, city: str, response_pattern: str) -> str: j = text.json() - city = j['name'] - country = j['sys']['country'] - fahrenheit = to_fahrenheit(j['main']['temp']) - celsius = to_celsius(j['main']['temp']) - description = j['weather'][0]['description'].title() + city = j["name"] + country = j["sys"]["country"] + fahrenheit = to_fahrenheit(j["main"]["temp"]) + celsius = to_celsius(j["main"]["temp"]) + description = j["weather"][0]["description"].title() return response_pattern.format(city, country, fahrenheit, celsius, description) @@ -67,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 8c7df56272..307bc299ff 100755 --- a/zulip_bots/zulip_bots/bots/wikipedia/test_wikipedia.py +++ b/zulip_bots/zulip_bots/bots/wikipedia/test_wikipedia.py @@ -1,5 +1,6 @@ -from zulip_bots.test_lib import BotTestCase, DefaultTests from zulip_bots.request_test_lib import mock_request_exception +from zulip_bots.test_lib import BotTestCase, DefaultTests + class TestWikipediaBot(BotTestCase, DefaultTests): bot_name = "wikipedia" @@ -7,63 +8,67 @@ class TestWikipediaBot(BotTestCase, DefaultTests): def test_bot(self) -> None: # Single-word query - bot_request = 'happy' - bot_response = ('''For search term:happy + bot_request = "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'): +""" + 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_request = "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'): +""" + 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_request = "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'): +""" + with self.mock_http_conversation("test_number_query"): self.verify_reply(bot_request, bot_response) # Hash query - bot_request = '#' - bot_response = '''For search term:# + bot_request = "#" + bot_response = """For search term:# 1:[Number_sign](https://en.wikipedia.org/wiki/Number_sign) -''' - with self.mock_http_conversation('test_hash_query'): +""" + with self.mock_http_conversation("test_hash_query"): self.verify_reply(bot_request, bot_response) # Incorrect word - bot_request = 'sssssss kkkkk' - bot_response = "I am sorry. The search term you provided is not found :slightly_frowning_face:" - with self.mock_http_conversation('test_incorrect_query'): + bot_request = "sssssss kkkkk" + 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) # Empty query, no request made to the Internet. - bot_request = '' + bot_request = "" bot_response = "Please enter your search term after @**test-bot**" self.verify_reply(bot_request, bot_response) # 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_request = "Zulip" + 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'): + with self.mock_http_conversation("test_status_code"): self.verify_reply(bot_request, bot_response) # Request Exception - bot_request = 'Z' + bot_request = "Z" with mock_request_exception(): 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 2f25f47ad9..ff707fbf23 100644 --- a/zulip_bots/zulip_bots/bots/wikipedia/wikipedia.py +++ b/zulip_bots/zulip_bots/bots/wikipedia/wikipedia.py @@ -1,12 +1,15 @@ -import requests import logging from typing import Dict + +import requests + from zulip_bots.lib import BotHandler # See readme.md for instructions on running this code. + class WikipediaHandler: - ''' + """ This plugin facilitates searching Wikipedia for a specific key term and returns the top 3 articles from the search. It looks for messages starting with '@mention-bot' @@ -15,66 +18,77 @@ class WikipediaHandler: the same stream that it was called from, but this code could be adapted to write Wikipedia searches to some kind of external issue tracker as well. - ''' + """ META = { - 'name': 'Wikipedia', - 'description': 'Searches Wikipedia for a term and returns the top 3 articles.', + "name": "Wikipedia", + "description": "Searches Wikipedia for a term and returns the top 3 articles.", } def usage(self) -> str: - return ''' + return """ This plugin will allow users to directly search Wikipedia for a specific key term and get the top 3 articles that is returned from the search. Users should preface searches with "@mention-bot". - @mention-bot ''' + @mention-bot """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: bot_response = self.get_bot_wiki_response(message, bot_handler) bot_handler.send_reply(message, bot_response) def get_bot_wiki_response(self, message: Dict[str, str], bot_handler: BotHandler) -> str: - '''This function returns the URLs of the requested topic.''' + """This function returns the URLs of the requested topic.""" - help_text = 'Please enter your search term after {}' + help_text = "Please enter your search term after {}" # Checking if the link exists. - query = message['content'] - if query == '': + query = message["content"] + if query == "": 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_url = "https://en.wikipedia.org/w/api.php" + 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.' + logging.error("broken link") + 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.' + logging.error("Page not found.") + 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' + 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:' + if len(data.json()["query"]["search"]) == 0: + 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' + 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" + ) 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 6248dcf826..4d7c6a6542 100644 --- a/zulip_bots/zulip_bots/bots/witai/test_witai.py +++ b/zulip_bots/zulip_bots/bots/witai/test_witai.py @@ -1,49 +1,44 @@ +from typing import Any, Dict, Optional from unittest.mock import patch -from typing import Dict, Any, Optional -from zulip_bots.test_lib import BotTestCase, DefaultTests, get_bot_message_handler, StubBotHandler + +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_bot_message_handler + class TestWitaiBot(BotTestCase, DefaultTests): - bot_name = 'witai' + bot_name = "witai" MOCK_CONFIG_INFO = { - 'token': '12345678', - 'handler_location': '/Users/abcd/efgh', - 'help_message': 'Qwertyuiop!' + "token": "12345678", + "handler_location": "/Users/abcd/efgh", + "help_message": "Qwertyuiop!", } MOCK_WITAI_RESPONSE = { - '_text': 'What is your favorite food?', - 'entities': { - 'intent': [{ - 'confidence': 1.0, - 'value': 'favorite_food' - }] - } + "_text": "What is your favorite food?", + "entities": {"intent": [{"confidence": 1.0, "value": "favorite_food"}]}, } def test_normal(self) -> None: - with patch('zulip_bots.bots.witai.witai.get_handle', return_value=mock_handle): + with patch("zulip_bots.bots.witai.witai.get_handle", return_value=mock_handle): with self.mock_config_info(self.MOCK_CONFIG_INFO): 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' - ) + with patch("wit.Wit.message", return_value=self.MOCK_WITAI_RESPONSE): + 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: - with patch('zulip_bots.bots.witai.witai.get_handle', return_value=mock_handle): + with patch("zulip_bots.bots.witai.witai.get_handle", return_value=mock_handle): with self.mock_config_info(self.MOCK_CONFIG_INFO): get_bot_message_handler(self.bot_name).initialize(StubBotHandler()) - with patch('wit.Wit.message', return_value=self.MOCK_WITAI_RESPONSE): - self.verify_reply('', 'Qwertyuiop!') + 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' - if res['entities']['intent'][0]['value'] == 'favorite_drink': - return 'coffee' + if res["entities"]["intent"][0]["value"] == "favorite_food": + return "pizza" + if res["entities"]["intent"][0]["value"] == "favorite_drink": + return "coffee" return None diff --git a/zulip_bots/zulip_bots/bots/witai/witai.py b/zulip_bots/zulip_bots/bots/witai/witai.py index 05a874ae92..1adb950d82 100644 --- a/zulip_bots/zulip_bots/bots/witai/witai.py +++ b/zulip_bots/zulip_bots/bots/witai/witai.py @@ -1,65 +1,70 @@ # See readme.md for instructions on running this code. -from typing import Dict, Any, Optional, Callable -from zulip_bots.lib import BotHandler -import wit import importlib.abc import importlib.util +from typing import Any, Callable, Dict, Optional + +import wit + +from zulip_bots.lib import BotHandler + class WitaiHandler: def usage(self) -> str: - return ''' + return """ Wit.ai bot uses pywit API to interact with Wit.ai. In order to use Wit.ai bot, `witai.conf` must be set up. See `doc.md` for more details. - ''' + """ def initialize(self, bot_handler: BotHandler) -> None: - config = bot_handler.get_config_info('witai') + config = bot_handler.get_config_info("witai") - token = config.get('token') + token = config.get("token") if not token: - raise KeyError('No `token` was specified') + raise KeyError("No `token` was specified") # `handler_location` should be the location of a module which contains # the function `handle`. See `doc.md` for more details. - handler_location = config.get('handler_location') + handler_location = config.get("handler_location") if not handler_location: - raise KeyError('No `handler_location` was specified') + raise KeyError("No `handler_location` was specified") handle = get_handle(handler_location) if handle is None: - raise Exception('Could not get handler from handler_location.') + raise Exception("Could not get handler from handler_location.") else: self.handle = handle - help_message = config.get('help_message') + help_message = config.get("help_message") if not help_message: - raise KeyError('No `help_message` was specified') + raise KeyError("No `help_message` was specified") self.help_message = help_message self.client = wit.Wit(token) def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - if message['content'] == '' or message['content'] == 'help': + if message["content"] == "" or message["content"] == "help": bot_handler.send_reply(message, self.help_message) return try: - res = self.client.message(message['content']) + res = self.client.message(message["content"]) message_for_user = self.handle(res) if message_for_user: bot_handler.send_reply(message, message_for_user) except wit.wit.WitError: - bot_handler.send_reply(message, 'Sorry, I don\'t know how to respond to that!') + bot_handler.send_reply(message, "Sorry, I don't know how to respond to that!") except Exception as e: - bot_handler.send_reply(message, 'Sorry, there was an internal error.') + bot_handler.send_reply(message, "Sorry, there was an internal error.") 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 + """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 given `location`. For an example of a `handle` function, see `doc.md`. @@ -72,9 +77,9 @@ def get_handle(location: str) -> Optional[Callable[[Dict[str, Any]], Optional[st Parameters: - location: The absolute path to the module to look for `handle` in. - ''' + """ try: - spec = importlib.util.spec_from_file_location('module.name', location) + spec = importlib.util.spec_from_file_location("module.name", location) handler = importlib.util.module_from_spec(spec) loader = spec.loader if not isinstance(loader, importlib.abc.Loader): diff --git a/zulip_bots/zulip_bots/bots/xkcd/test_xkcd.py b/zulip_bots/zulip_bots/bots/xkcd/test_xkcd.py index 5ef5eb3599..7a363b86a4 100755 --- a/zulip_bots/zulip_bots/bots/xkcd/test_xkcd.py +++ b/zulip_bots/zulip_bots/bots/xkcd/test_xkcd.py @@ -1,54 +1,65 @@ from unittest.mock import MagicMock, patch + from zulip_bots.test_lib import BotTestCase, DefaultTests + 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)") - with self.mock_http_conversation('test_latest'): - self.verify_reply('latest', bot_response) + 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)") - with self.mock_http_conversation('test_random'): + 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: + with patch("zulip_bots.bots.xkcd.xkcd.random.randint") as randint: mock_rand_value = MagicMock() mock_rand_value.return_value = 1800 randint.return_value = mock_rand_value.return_value - self.verify_reply('random', bot_response) + 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)") - with self.mock_http_conversation('test_specific_id'): - self.verify_reply('1', bot_response) + 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) - @patch('logging.exception') + @patch("logging.exception") 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 `{}`:" - commands = ''' + 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**") - self.verify_reply('', err_txt.format('') + commands) - self.verify_reply('help', help_txt + commands) +* `{0} ` to fetch a comic strip based on `` e.g `{0} 1234`.""".format( + "@**test-bot**" + ) + self.verify_reply("", err_txt.format("") + commands) + self.verify_reply("help", help_txt + commands) # Example invalid command - self.verify_reply('x', err_txt.format('x') + commands) + self.verify_reply("x", err_txt.format("x") + commands) diff --git a/zulip_bots/zulip_bots/bots/xkcd/xkcd.py b/zulip_bots/zulip_bots/bots/xkcd/xkcd.py index 1cdb231277..ee1b607ced 100644 --- a/zulip_bots/zulip_bots/bots/xkcd/xkcd.py +++ b/zulip_bots/zulip_bots/bots/xkcd/xkcd.py @@ -1,29 +1,30 @@ +import logging import random +from typing import Dict, Optional -import logging import requests -from typing import Dict, Optional 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' +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 strip from https://xkcd.com. The bot looks for messages starting with "@mention-bot" and responds with a message with the comic based on provided commands. - ''' + """ META = { - 'name': 'XKCD', - 'description': 'Fetches comic strips from https://xkcd.com.', + "name": "XKCD", + "description": "Fetches comic strips from https://xkcd.com.", } def usage(self) -> str: - return ''' + return """ This plugin allows users to fetch a comic strip provided by https://xkcd.com. Users should preface the command with "@mention-bot". @@ -33,58 +34,66 @@ def usage(self) -> str: - @mention-bot random -> To fetch a random comic strip from xkcd. - @mention-bot -> To fetch a comic strip based on ``, e.g `@mention-bot 1234`. - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: quoted_name = bot_handler.identity().mention 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() + 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': - return commands_help % ('xkcd bot supports these commands:',) - elif command == 'latest': + if command == "help": + return commands_help % ("xkcd bot supports these commands:",) + elif command == "latest": fetched = fetch_xkcd_query(XkcdBotCommand.LATEST) - elif command == 'random': + elif command == "random": fetched = fetch_xkcd_query(XkcdBotCommand.RANDOM) 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 % (f"xkcd bot only supports these commands, not `{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!' + 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,)) - return 'Sorry, there is likely no xkcd comic strip with id: #%s' % (command,) + logging.exception(f"XKCD server responded 404 when trying to fetch comic with id {command}") + return f"Sorry, there is likely no xkcd comic strip with id: #{command}" else: - return ("#%s: **%s**\n[%s](%s)" % (fetched['num'], - fetched['title'], - fetched['alt'], - fetched['img'])) + return "#{}: **{}**\n[{}]({})".format( + fetched["num"], + fetched["title"], + fetched["alt"], + fetched["img"], + ) + def fetch_xkcd_query(mode: int, comic_id: Optional[str] = None) -> Dict[str, str]: try: @@ -97,13 +106,13 @@ def fetch_xkcd_query(mode: int, comic_id: Optional[str] = None) -> Dict[str, str if latest.status_code != 200: raise XkcdServerError() - latest_id = latest.json()['num'] + latest_id = latest.json()["num"] random_id = random.randint(1, latest_id) url = XKCD_TEMPLATE_URL % (str(random_id),) elif mode == XkcdBotCommand.COMIC_ID: # Fetch specific comic strip by id number. if comic_id is None: - raise Exception('Missing comic_id argument') + raise Exception("Missing comic_id argument") url = XKCD_TEMPLATE_URL % (comic_id,) fetched = requests.get(url) @@ -120,4 +129,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 0b1689e2d8..b37696dff7 100644 --- a/zulip_bots/zulip_bots/bots/yoda/test_yoda.py +++ b/zulip_bots/zulip_bots/bots/yoda/test_yoda.py @@ -1,12 +1,13 @@ +from typing import Optional + from zulip_bots.bots.yoda.yoda import ServiceUnavailableError from zulip_bots.test_lib import BotTestCase, DefaultTests -from typing import Optional class TestYodaBot(BotTestCase, DefaultTests): bot_name = "yoda" - help_text = ''' + help_text = """ This bot allows users to translate a sentence into 'Yoda speak'. Users should preface messages with '@mention-bot'. @@ -18,10 +19,10 @@ class TestYodaBot(BotTestCase, DefaultTests): directory. Example input: @mention-bot You will learn how to speak like me someday. - ''' + """ def _test(self, message: str, response: str, fixture: Optional[str] = None) -> None: - with self.mock_config_info({'api_key': '12345678'}): + with self.mock_config_info({"api_key": "12345678"}): if fixture is not None: with self.mock_http_conversation(fixture): self.verify_reply(message, response) @@ -30,43 +31,50 @@ def _test(self, message: str, response: str, fixture: Optional[str] = None) -> N # Override default function in BotTestCase def test_bot_responds_to_empty_message(self) -> None: - self._test('', self.help_text) + self._test("", self.help_text) 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) + 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 323777e04f..4e1cfc5d55 100644 --- a/zulip_bots/zulip_bots/bots/yoda/yoda.py +++ b/zulip_bots/zulip_bots/bots/yoda/yoda.py @@ -1,12 +1,13 @@ # See readme.md for instructions on running this code. import logging import ssl +from typing import Dict + import requests -from typing import Dict from zulip_bots.lib import BotHandler -HELP_MESSAGE = ''' +HELP_MESSAGE = """ This bot allows users to translate a sentence into 'Yoda speak'. Users should preface messages with '@mention-bot'. @@ -18,26 +19,28 @@ directory. Example input: @mention-bot You will learn how to speak like me someday. - ''' + """ class ApiKeyError(Exception): - '''raise this when there is an error with the Mashape Api Key''' + """raise this when there is an error with the Mashape Api Key""" + class ServiceUnavailableError(Exception): - '''raise this when the service is unavailable.''' + """raise this when the service is unavailable.""" 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'] + self.api_key = bot_handler.get_config_info("yoda")["api_key"] def usage(self) -> str: - return ''' + return """ This bot will allow users to translate a sentence into 'Yoda speak'. Users should preface messages with '@mention-bot'. @@ -48,44 +51,46 @@ def usage(self) -> str: The 'yoda.conf' file should be located in this bot's directory. Example input: @mention-bot You will learn how to speak like me someday. - ''' + """ def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: self.handle_input(message, bot_handler) 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'] + return response.json()["text"] if response.status_code == 403: raise ApiKeyError if response.status_code == 503: raise ServiceUnavailableError else: - error_message = response.json()['message'] + 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: # gets rid of whitespace around the edges, so that they aren't a problem in the future message_content = original_content.strip() # replaces all spaces with '+' to be in the format the api requires - sentence = message_content.replace(' ', '+') + sentence = message_content.replace(" ", "+") return sentence def handle_input(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - original_content = message['content'] + original_content = message["content"] if self.is_help(original_content) or (original_content == ""): bot_handler.send_reply(message, HELP_MESSAGE) @@ -96,33 +101,33 @@ def handle_input(self, message: Dict[str, str], bot_handler: BotHandler) -> None reply_message = self.send_to_yoda_api(sentence) if len(reply_message) == 0: - reply_message = 'Invalid input, please check the sentence you have entered.' + reply_message = "Invalid input, please check the sentence you have entered." except (ssl.SSLError, TypeError): - reply_message = 'The service is temporarily unavailable, please try again.' + reply_message = "The service is temporarily unavailable, please try again." 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 message_content = original_content.strip() - if message_content == 'help': + if message_content == "help": return True 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 13f69fa204..99655c2457 100644 --- a/zulip_bots/zulip_bots/bots/youtube/test_youtube.py +++ b/zulip_bots/zulip_bots/bots/youtube/test_youtube.py @@ -1,92 +1,109 @@ +from typing import Dict from unittest.mock import patch -from requests.exceptions import HTTPError, ConnectionError -from zulip_bots.test_lib import StubBotHandler, BotTestCase, DefaultTests, get_bot_message_handler -from typing import Dict +from requests.exceptions import ConnectionError, HTTPError + +from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_bot_message_handler + class TestYoutubeBot(BotTestCase, DefaultTests): bot_name = "youtube" - 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" + 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" + ) # 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'): - self.verify_reply('', self.help_content) + 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'): - self.verify_reply('funny cats', bot_response) + 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'): - self.verify_reply('list marvel', bot_response) + 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:' - - with self.mock_config_info(self.normal_config), \ - self.mock_http_conversation('test_noresult'): - self.verify_reply('somethingrandomwithnoresult', bot_response,) + 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, + ) def test_help(self) -> None: help_content = self.help_content - 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) + 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 3c354cf821..7e58300df6 100644 --- a/zulip_bots/zulip_bots/bots/youtube/youtube.py +++ b/zulip_bots/zulip_bots/bots/youtube/youtube.py @@ -1,128 +1,136 @@ -import requests import logging +from typing import Dict, List, Optional, Tuple, Union + +import requests +from requests.exceptions import ConnectionError, HTTPError -from requests.exceptions import HTTPError, ConnectionError -from typing import Dict, Union, List, Tuple, Optional from zulip_bots.lib import BotHandler -commands_list = ('list', 'top', 'help') +commands_list = ("list", "top", "help") -class YoutubeHandler: +class YoutubeHandler: def usage(self) -> str: - return ''' + 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') + self.config_info = bot_handler.get_config_info("youtube") # Check if API key is valid. If it is not valid, don't run the bot. try: - search_youtube('test', self.config_info['key'], self.config_info['video_region']) + 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: - logging.warning('Bad connection') + logging.warning("Bad connection") def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - if message['content'] == '' or message['content'] == 'help': + if message["content"] == "" or message["content"] == "help": 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 = { - 'part': 'id,snippet', - 'maxResults': max_results, - 'key': key, - 'q': query, - 'alt': 'json', - 'type': 'video', - 'regionCode': region} # type: Dict[str, Union[str, int]] - - url = 'https://www.googleapis.com/youtube/v3/search' + "part": "id,snippet", + "maxResults": max_results, + "key": key, + "q": query, + "alt": "json", + "type": "video", + "regionCode": region, + } # type: Dict[str, Union[str, int]] + + url = "https://www.googleapis.com/youtube/v3/search" try: r = requests.get(url, params=params) except ConnectionError: # Usually triggered by bad connection. - logging.exception('Bad connection') + logging.exception("Bad connection") raise r.raise_for_status() search_response = r.json() # Add each result to the appropriate list, and then display the lists of # 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']]) + 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"]]) return videos def get_command_query(message: Dict[str, str]) -> Tuple[Optional[str], str]: - blocks = message['content'].lower().split() + 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'] + 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]] + key = config_info["key"] + max_results = int(config_info["number_of_results"]) + region = config_info["video_region"] + video_list = [] # type: List[List[str]] try: - if query == '' or query is None: + if query == "" or query is None: return YoutubeHandler.help_content - if command is None or command == 'top': + if command is None or command == "top": video_list = search_youtube(query, key, region) - elif command == 'list': + elif command == "list": video_list = search_youtube(query, key, region, max_results) - elif command == 'help': + elif command == "help": 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 + '` : ' + 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 + f"\n * {title} - [Watch now](https://www.youtube.com/watch/{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 7b7f92b967..3af95c730d 100644 --- a/zulip_bots/zulip_bots/custom_exceptions.py +++ b/zulip_bots/zulip_bots/custom_exceptions.py @@ -4,8 +4,9 @@ # 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() is invalid (e.g. wrong API key, invalid email, etc.). - ''' + """ diff --git a/zulip_bots/zulip_bots/finder.py b/zulip_bots/zulip_bots/finder.py index 3b9f5be0e7..354faf829c 100644 --- a/zulip_bots/zulip_bots/finder.py +++ b/zulip_bots/zulip_bots/finder.py @@ -2,12 +2,13 @@ import importlib.abc import importlib.util import os -from typing import Any, Optional, Text, Tuple from pathlib import Path +from typing import Any, Optional, Tuple current_dir = os.path.dirname(os.path.abspath(__file__)) -def import_module_from_source(path: Text, name: Text) -> Any: + +def import_module_from_source(path: str, name: str) -> Any: spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) loader = spec.loader @@ -16,20 +17,22 @@ def import_module_from_source(path: Text, name: Text) -> Any: loader.exec_module(module) return module -def import_module_by_name(name: Text) -> Any: + +def import_module_by_name(name: str) -> Any: try: return importlib.import_module(name) except ImportError: return None -def resolve_bot_path(name: Text) -> Optional[Tuple[Path, Text]]: + +def resolve_bot_path(name: str) -> Optional[Tuple[Path, str]]: if os.path.isfile(name): bot_path = Path(name) bot_name = Path(bot_path).stem return (bot_path, bot_name) else: bot_name = name - bot_path = Path(current_dir, 'bots', bot_name, bot_name + '.py') + bot_path = Path(current_dir, "bots", bot_name, bot_name + ".py") if os.path.isfile(bot_path): return (bot_path, bot_name) diff --git a/zulip_bots/zulip_bots/game_handler.py b/zulip_bots/zulip_bots/game_handler.py index 7da12a2a15..4377496113 100644 --- a/zulip_bots/zulip_bots/game_handler.py +++ b/zulip_bots/zulip_bots/game_handler.py @@ -1,10 +1,11 @@ import json -import re -import random import logging -from zulip_bots.lib import BotHandler +import random +import re from copy import deepcopy -from typing import Any, Dict, Tuple, List +from typing import Any, Dict, List, Tuple + +from zulip_bots.lib import BotHandler class BadMoveException(Exception): @@ -14,6 +15,7 @@ def __init__(self, message: str) -> None: def __str__(self) -> str: return self.message + class SamePlayerMove(Exception): def __init__(self, message: str) -> None: self.message = message @@ -21,13 +23,14 @@ def __init__(self, message: str) -> None: def __str__(self) -> str: return self.message + class GameAdapter: - ''' + """ Class that serves as a template to easily create multiplayer games. This class handles all commands, and creates GameInstances which run the actual game logic. - ''' + """ def __init__( self, @@ -40,7 +43,7 @@ def __init__( 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 @@ -56,24 +59,24 @@ def __init__( self.instances = {} # type: Dict[str, Any] self.user_cache = {} # type: Dict[str, Dict[str, Any]] self.pending_subject_changes = [] # type: List[str] - self.stream = 'games' + self.stream = "games" self.rules = rules # Values are [won, lost, drawn, total] new values can be added, but MUST be added to the end of the list. def add_user_statistics(self, user: str, values: Dict[str, int]) -> None: self.get_user_cache() current_values = {} # type: Dict[str, int] - if 'stats' in self.get_user_by_email(user).keys(): - current_values = self.user_cache[user]['stats'] + if "stats" in self.get_user_by_email(user).keys(): + current_values = self.user_cache[user]["stats"] for key, value in values.items(): if key not in current_values.keys(): current_values.update({key: 0}) current_values[key] += value - self.user_cache[user].update({'stats': current_values}) + self.user_cache[user].update({"stats": current_values}) self.put_user_cache() def help_message(self) -> str: - return '''** {} Bot Help:** + return """** {} Bot Help:** *Preface all commands with @**{}*** * To start a game in a stream (*recommended*), type `start game` @@ -93,10 +96,15 @@ def help_message(self) -> str: `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:** + return """** {} Bot Help:** *Preface all commands with @**{}*** * To start a game in a stream, type `start game` @@ -104,18 +112,20 @@ def help_message_single_player(self) -> str: `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() return { - 'accept': action, - 'decline': action, - 'register': action, - 'draw': action, - 'forfeit': action, - 'leaderboard': action, - 'join': action, + "accept": action, + "decline": action, + "register": action, + "draw": action, + "forfeit": action, + "leaderboard": action, + "join": action, } def manage_command(self, command: str, message: Dict[str, Any]) -> int: @@ -127,53 +137,74 @@ def manage_command(self, command: str, message: Dict[str, Any]) -> int: return 0 def already_in_game_message(self) -> str: - return 'You are already in a game. Type `quit` to leave.' + 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 '' + 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)) + host = self.invites[game_id]["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)) + host = self.invites[game_id]["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 - )) + 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 + ) + ) 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 @@ -184,25 +215,26 @@ def initialize(self, bot_handler: BotHandler) -> None: def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: try: self.bot_handler = bot_handler - content = message['content'].strip() - sender = message['sender_email'].lower() - message['sender_email'] = message['sender_email'].lower() + content = message["content"].strip() + sender = message["sender_email"].lower() + 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 if sender not in self.user_cache.keys(): self.add_user_to_cache(message) - logging.info('Added {} to user cache'.format(sender)) + logging.info(f"Added {sender} to user cache") 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: @@ -210,50 +242,57 @@ def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> No if val == 0: return - if content.lower() == 'help' or content == '': + if content.lower() == "help" or content == "": if self.is_single_player: self.send_reply(message, self.help_message_single_player()) else: self.send_reply(message, self.help_message()) return - elif content.lower() == 'rules': + elif content.lower() == "rules": self.send_reply(message, self.rules) - elif content.lower().startswith('start game with '): + elif content.lower().startswith("start game with "): self.command_start_game_with(message, sender, content) - elif content.lower() == 'start game': + elif content.lower() == "start game": self.command_start_game(message, sender, content) - elif content.lower().startswith('play game'): + elif content.lower().startswith("play game"): self.command_play(message, sender, content) - elif content.lower() == 'accept': + elif content.lower() == "accept": self.command_accept(message, sender, content) - elif content.lower() == 'decline': + elif content.lower() == "decline": self.command_decline(message, sender, content) - elif content.lower() == 'quit': + elif content.lower() == "quit": self.command_quit(message, sender, content) - elif content.lower() == 'register': + 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': + elif content.lower() == "leaderboard": self.command_leaderboard(message, sender, content) - elif content.lower() == 'join': + elif content.lower() == "join": self.command_join(message, sender, content) - elif self.is_user_in_game(sender) != '': + 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()) @@ -261,33 +300,33 @@ def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> No self.send_reply(message, self.help_message()) except Exception as e: logging.exception(str(e)) - self.bot_handler.send_reply(message, 'Error {}.'.format(e)) + self.bot_handler.send_reply(message, f"Error {e}.") def is_user_in_game(self, user_email: str) -> str: for instance in self.instances.values(): if user_email in instance.players: return instance.game_id - return '' + return "" 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(', ') + users = content.replace("start game with ", "").strip().split(", ") self.create_game_lobby(message, users) def command_start_game(self, message: Dict[str, Any], sender: str, content: str) -> None: - if message['type'] == 'private': + if message["type"] == "private": if self.is_single_player: - self.send_reply(message, 'You are not allowed to play games in private messages.') + self.send_reply(message, "You are not allowed to play games in private messages.") 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: @@ -295,174 +334,191 @@ def command_start_game(self, message: Dict[str, Any], sender: str, content: str) 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.') + if game_id == "": + self.send_reply(message, "No active invites. Type `help` for commands.") return - if message['type'] == 'private': + 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, + f"@**{self.get_username_by_email(sender)}** has accepted the invitation.", + ) self.start_game_if_ready(game_id) def create_game_lobby(self, message: Dict[str, Any], users: List[str] = []) -> None: - if self.is_game_in_subject(message['subject'], message['display_recipient']): - self.send_reply(message, 'There is already a game in this stream.') + if self.is_game_in_subject(message["subject"], message["display_recipient"]): + self.send_reply(message, "There is already a game in this stream.") return if len(users) > 0: 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, + f"The maximum number of players for this game is {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']} - if message['type'] == 'private': - self.invites[game_id]['stream'] = 'games' + 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"], + } + 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 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( - self.get_username_by_email(message['sender_email']), + 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) - if message['type'] == 'stream': + 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, f"@**{self.get_bot_username()}** accept", 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.') + if game_id == "": + 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))) - if len(self.get_players(game_id, parameter='')) < self.min_players: + game_id, + f"@**{self.get_username_by_email(sender)}** has declined the invitation.", + ) + if len(self.get_players(game_id, parameter="")) < self.min_players: self.cancel_game(game_id) def command_quit(self, message: Dict[str, Any], sender: str, content: str) -> None: game_id = self.get_game_id_by_email(sender) - if message['type'] == 'private' and self.is_single_player: - self.send_reply(message, 'You are not allowed to play games in private messages.') + if message["type"] == "private" and self.is_single_player: + 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.') + if game_id == "": + 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)) + self.cancel_game(game_id, reason=f"**{sender_name}** quit.") 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': + 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']) - if game_id == '': + 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']) - if game_id == '': + 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, + f"Join {self.max_players - num_players} more players to start the game", ) def command_leaderboard(self, message: Dict[str, Any], sender: str, content: str) -> None: stats = self.get_sorted_player_statistics() num = 5 if len(stats) > 5 else len(stats) 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] - response += ' | '.join(headers) - response += '\n' + ' | '.join([' --- ' for header in headers]) + 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] + 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 += f"\n **{self.get_username_by_email(player)}** | " values = [str(stat[key]) for key in raw_headers] - response += ' | '.join(values) + response += " | ".join(values) self.send_reply(message, response) return def get_sorted_player_statistics(self) -> List[Tuple[str, Dict[str, int]]]: players = [] for user_name, u in self.user_cache.items(): - if 'stats' in u.keys(): - players.append((user_name, u['stats'])) + if "stats" in u.keys(): + 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: - self.invites[game_id].update({user_email.lower(): 'p'}) + self.invites[game_id].update({user_email.lower(): "p"}) self.send_message(user_email, self.alert_new_invitation(game_id), True) if message != {}: self.send_reply(message, self.confirm_new_invitation(user_email)) - def cancel_game(self, game_id: str, reason: str = '') -> None: + def cancel_game(self, game_id: str, reason: str = "") -> None: if game_id in self.invites.keys(): - self.broadcast(game_id, 'Game cancelled.\n' + reason) + self.broadcast(game_id, "Game cancelled.\n" + reason) del self.invites[game_id] return if game_id in self.instances.keys(): - self.instances[game_id].broadcast('Game ended.\n' + reason) + self.instances[game_id].broadcast("Game ended.\n" + reason) del self.instances[game_id] return @@ -474,49 +530,58 @@ def start_game_if_ready(self, game_id: str) -> None: def start_game(self, game_id: str) -> None: players = self.get_players(game_id) subject = game_id - 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)) + 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, + f"The game has started in #{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 `{}`** + 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) + object += "\n> **[Join Game](/#narrow/stream/{}/topic/{})**".format( + instance.stream, instance.subject + ) return object def join_game(self, game_id: str, user_email: str, message: Dict[str, Any] = {}) -> None: if len(self.get_players(game_id)) >= self.max_players: if message != {}: - self.send_reply(message, 'This game is full.') + self.send_reply(message, "This game is full.") return - self.invites[game_id].update({user_email: 'a'}) + 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, f"@**{self.get_username_by_email(user_email)}** has joined the game" + ) self.start_game_if_ready(game_id) - def get_players(self, game_id: str, parameter: str = 'a') -> List[str]: + 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: - players = [self.invites[game_id]['host']] + 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': + if player == "host" or player == "subject" or player == "stream": continue if parameter in accepted: players.append(player) return players - if game_id in self.instances.keys() and 'p' not in parameter: + if game_id in self.instances.keys() and "p" not in parameter: players = self.instances[game_id].players return players return [] @@ -526,28 +591,28 @@ def get_game_info(self, game_id: str) -> Dict[str, Any]: if game_id in self.instances.keys(): instance = self.instances[game_id] game_info = { - 'game_id': game_id, - 'type': 'instance', - 'stream': instance.stream, - 'subject': instance.subject, - 'players': self.get_players(game_id) + "game_id": game_id, + "type": "instance", + "stream": instance.stream, + "subject": instance.subject, + "players": self.get_players(game_id), } if game_id in self.invites.keys(): invite = self.invites[game_id] game_info = { - 'game_id': game_id, - 'type': 'invite', - 'stream': invite['stream'], - 'subject': invite['subject'], - 'players': self.get_players(game_id) + "game_id": game_id, + "type": "invite", + "stream": invite["stream"], + "subject": invite["subject"], + "players": self.get_players(game_id), } return game_info def get_user_by_name(self, name: str) -> Dict[str, Any]: name = name.strip() for user in self.user_cache.values(): - if 'full_name' in user.keys(): - if user['full_name'].lower() == name.lower(): + if "full_name" in user.keys(): + if user["full_name"].lower() == name.lower(): return user return {} @@ -556,83 +621,84 @@ def get_number_of_players(self, game_id: str) -> int: return num def parse_message(self, message: Dict[str, Any]) -> None: - game_id = self.is_user_in_game(message['sender_email']) + game_id = self.is_user_in_game(message["sender_email"]) game = self.get_game_info(game_id) - if message['type'] == 'private': + if message["type"] == "private": 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["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) if game_id in self.invites.keys(): invite = self.invites[game_id] - invite['stream'] = stream_name - invite['subject'] = stream_name + 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(): if is_accepted: - if message['type'] == 'private': - users[user_email] = 'pa' + if message["type"] == "private": + users[user_email] = "pa" else: - users[user_email] = 'a' + users[user_email] = "a" else: users.pop(user_email) return game - return '' + return "" def add_user_to_cache(self, message: Dict[str, Any]) -> None: 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 - } + "email": message["sender_email"].lower(), + "full_name": message["sender_full_name"], + "stats": {"total_games": 0, "games_won": 0, "games_lost": 0, "games_drawn": 0}, } - self.user_cache.update({message['sender_email'].lower(): user}) + self.user_cache.update({message["sender_email"].lower(): user}) self.put_user_cache() def put_user_cache(self) -> Dict[str, Any]: user_cache_str = json.dumps(self.user_cache) - self.bot_handler.storage.put('users', user_cache_str) + self.bot_handler.storage.put("users", user_cache_str) return self.user_cache def get_user_cache(self) -> Dict[str, Any]: try: - user_cache_str = self.bot_handler.storage.get('users') + user_cache_str = self.bot_handler.storage.get("users") except KeyError: return {} self.user_cache = json.loads(user_cache_str) @@ -642,18 +708,23 @@ def verify_users(self, users: List[str], message: Dict[str, Any] = {}) -> List[s verified_users = [] 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 '@' not in user: + 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 "@" 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'] + user = user_obj["email"] if self.is_user_not_player(user, message): verified_users.append(user) else: @@ -671,60 +742,70 @@ def get_game_instance_by_subject(self, subject_name: str, stream_name: str) -> A def get_invite_in_subject(self, subject_name: str, stream_name: str) -> str: for key, invite in self.invites.items(): - if invite['subject'] == subject_name and invite['stream'] == stream_name: + if invite["subject"] == subject_name and invite["stream"] == stream_name: return key - return '' + 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: return False for invite in self.invites.values(): for u in invite.keys(): - if u == 'host': - if user_email == invite['host']: + if u == "host": + if user_email == invite["host"]: return False - if u == user_email and 'a' in invite[u]: + if u == user_email and "a" in invite[u]: return False return True def generate_game_id(self) -> str: - id = '' - valid_characters = 'abcdefghijklmnopqrstuvwxyz0123456789' + id = "" + valid_characters = "abcdefghijklmnopqrstuvwxyz0123456789" for i in range(6): id += valid_characters[random.randrange(0, len(valid_characters))] return id def broadcast(self, game_id: str, content: str, include_private: bool = True) -> bool: if include_private: - private_recipients = self.get_players(game_id, parameter='p') + private_recipients = self.get_players(game_id, parameter="p") if private_recipients is not None: for user in private_recipients: self.send_message(user, content, True) if game_id in self.invites.keys(): - if self.invites[game_id]['subject'] != '###private###': + 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 def get_username_by_email(self, user_email: str) -> str: - return self.get_user_by_email(user_email)['full_name'] + return self.get_user_by_email(user_email)["full_name"] def get_user_by_email(self, user_email: str) -> Dict[str, Any]: if user_email in self.user_cache: @@ -739,14 +820,14 @@ def get_game_id_by_email(self, user_email: str) -> str: players = self.get_players(game_id) if user_email in players: return game_id - return '' + return "" def get_bot_username(self) -> str: return self.bot_handler.full_name class GameInstance: - ''' + """ The GameInstance class handles the game logic for a certain game, and is associated with a certain stream. @@ -754,9 +835,17 @@ class GameInstance: It only runs when the game is being played, not in the invite 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 @@ -782,49 +871,54 @@ def change_subject(self, stream: str, subject: str) -> None: self.broadcast_current_message() def get_player_text(self) -> str: - player_text = '' + player_text = "" for player in self.players: - player_text += ' @**{}**'.format( - self.gameAdapter.get_username_by_email(player)) + player_text += f" @**{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()) + start_message = "Game `{}` started.\n*Remember to start your message with* @**{}**".format( + self.game_id, self.gameAdapter.get_bot_username() + ) if not self.is_private: - player_text = '\n**Players**' + player_text = "\n**Players**" player_text += self.get_player_text() start_message += player_text - start_message += '\n' + self.gameAdapter.gameMessageHandler.game_start_message() + start_message += "\n" + self.gameAdapter.gameMessageHandler.game_start_message() return start_message def handle_message(self, content: str, player_email: str) -> None: - if content == 'forfeit': + if content == "forfeit": player_name = self.gameAdapter.get_username_by_email(player_email) - self.broadcast('**{}** forfeited!'.format(player_name)) - self.end_game('except:' + player_email) + self.broadcast(f"**{player_name}** forfeited!") + self.end_game("except:" + player_email) return - if content == 'draw': + if content == "draw": if player_email in self.current_draw.keys(): 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') + self.end_game("draw") return if self.is_turn_of(player_email): self.handle_current_player_command(content) else: if self.gameAdapter.is_single_player: - self.broadcast('It\'s your turn') + 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) @@ -854,13 +948,19 @@ def make_move(self, content: str, is_computer: bool) -> None: 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: self.broadcast_current_message() - if game_over == 'current turn': + if game_over == "current turn": game_over = self.players[self.turn] self.end_game(game_over) return @@ -871,43 +971,53 @@ def is_turn_of(self, player_email: str) -> bool: 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) game_over = self.model.determine_game_over(self.players) if game_over: self.broadcast_current_message() - if game_over == 'current turn': + if game_over == "current turn": 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) + self.make_move("", True) def next_turn(self) -> None: self.turn += 1 if self.turn >= len(self.players): self.turn = 0 if self.gameAdapter.is_single_player: - self.current_messages.append('It\'s your turn.') + 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) + self.make_move("", True) def broadcast_current_message(self) -> None: - content = '\n\n'.join(self.current_messages) + content = "\n\n".join(self.current_messages) self.broadcast(content) self.current_messages = [] @@ -915,29 +1025,28 @@ def parse_current_board(self) -> Any: return self.gameAdapter.gameMessageHandler.parse_board(self.model.current_board) def end_game(self, winner: str) -> None: - loser = '' - if winner == 'draw': - self.broadcast('It was a draw!') - elif winner.startswith('except:'): - loser = winner.lstrip('except:') + loser = "" + if winner == "draw": + self.broadcast("It was a draw!") + elif winner.startswith("except:"): + loser = winner.lstrip("except:") else: winner_name = self.gameAdapter.get_username_by_email(winner) - self.broadcast('**{}** won! :tada:'.format(winner_name)) + self.broadcast(f"**{winner_name}** won! :tada:") for u in self.players: - values = {'total_games': 1, 'games_won': 0, - 'games_lost': 0, 'games_drawn': 0} - if loser == '': + values = {"total_games": 1, "games_won": 0, "games_lost": 0, "games_drawn": 0} + if loser == "": if u == winner: - values.update({'games_won': 1}) - elif winner == 'draw': - values.update({'games_drawn': 1}) + values.update({"games_won": 1}) + elif winner == "draw": + values.update({"games_drawn": 1}) else: - values.update({'games_lost': 1}) + values.update({"games_lost": 1}) else: if u == loser: - values.update({'games_lost': 1}) + values.update({"games_lost": 1}) else: - values.update({'games_won': 1}) + values.update({"games_won": 1}) self.gameAdapter.add_user_statistics(u, values) if self.gameAdapter.email in self.players: self.send_win_responses(winner) @@ -945,8 +1054,8 @@ def end_game(self, winner: str) -> None: def send_win_responses(self, winner: str) -> None: if winner == self.gameAdapter.email: - self.broadcast('I won! Well Played!') - elif winner == 'draw': - self.broadcast('It was a draw! Well Played!') + self.broadcast("I won! Well Played!") + elif winner == "draw": + self.broadcast("It was a draw! Well Played!") else: - self.broadcast('You won! Nice!') + self.broadcast("You won! Nice!") diff --git a/zulip_bots/zulip_bots/lib.py b/zulip_bots/zulip_bots/lib.py index b6fe3345fb..d6d79f7c5a 100644 --- a/zulip_bots/zulip_bots/lib.py +++ b/zulip_bots/zulip_bots/lib.py @@ -2,15 +2,15 @@ import json import logging import os +import re import signal import sys import time -import re - - from contextlib import contextmanager -from typing import Any, Iterator, Optional, List, Dict, IO, Set, Text +from typing import IO, Any, Dict, Iterator, List, Optional, Set + from typing_extensions import Protocol + from zulip import Client, ZulipError @@ -28,7 +28,8 @@ def exit_gracefully(signum: int, frame: Optional[Any]) -> None: def get_bots_directory_path() -> str: current_dir = os.path.dirname(os.path.abspath(__file__)) - return os.path.join(current_dir, 'bots') + return os.path.join(current_dir, "bots") + def zulip_env_vars_are_present() -> bool: # We generally require a Zulip config file, but if @@ -36,24 +37,25 @@ def zulip_env_vars_are_present() -> bool: # waive the requirement. This can be helpful for # containers like Heroku that prefer env vars to config # files. - if os.environ.get('ZULIP_EMAIL') is None: + if os.environ.get("ZULIP_EMAIL") is None: return False - if os.environ.get('ZULIP_API_KEY') is None: + if os.environ.get("ZULIP_API_KEY") is None: return False - if os.environ.get('ZULIP_SITE') is None: + if os.environ.get("ZULIP_SITE") is None: return False # If none of the absolutely critical env vars are # 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 self.interval_limit = interval_limit self.message_list = [] # type: List[float] - self.error_message = '-----> !*!*!*MESSAGE RATE LIMIT REACHED, EXITING*!*!*! <-----\n' - 'Is your bot trapped in an infinite loop by reacting to its own messages?' + self.error_message = "-----> !*!*!*MESSAGE RATE LIMIT REACHED, EXITING*!*!*! <-----\n" + "Is your bot trapped in an infinite loop by reacting to its own messages?" def is_legal(self) -> bool: self.message_list.append(time.time()) @@ -68,22 +70,25 @@ def show_error_and_exit(self) -> None: 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 + '**' + self.mention = "@**" + name + "**" + class BotStorage(Protocol): - def put(self, key: Text, value: Any) -> None: + def put(self, key: str, value: Any) -> None: ... - def get(self, key: Text) -> Any: + def get(self, key: str) -> Any: ... - def contains(self, key: Text) -> bool: + def contains(self, key: str) -> 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. @@ -95,13 +100,13 @@ def __init__(self, parent_storage: BotStorage, init_data: Dict[str, Any]) -> Non self._cache = init_data self._dirty_keys: Set[str] = set() - def put(self, key: Text, value: Any) -> None: + def put(self, key: str, value: Any) -> None: # In the cached storage, values being put to the storage is not flushed to the parent storage. # It will be marked dirty until it get flushed. self._cache[key] = value self._dirty_keys.add(key) - def get(self, key: Text) -> Any: + def get(self, key: str) -> Any: # Unless the key is not found in the cache, the cached storage will not lookup the parent storage. if key in self._cache: return self._cache[key] @@ -118,46 +123,48 @@ def flush(self) -> None: key = self._dirty_keys.pop() self._parent_storage.put(key, self._cache[key]) - def flush_one(self, key: Text) -> None: + def flush_one(self, key: str) -> None: self._dirty_keys.remove(key) self._parent_storage.put(key, self._cache[key]) - def contains(self, key: Text) -> bool: + def contains(self, key: str) -> bool: if key in self._cache: return True else: return self._parent_storage.contains(key) + class StateHandler: def __init__(self, client: Client) -> None: self._client = client self.marshal = lambda obj: json.dumps(obj) self.demarshal = lambda obj: json.loads(obj) - self.state_ = dict() # type: Dict[Text, Any] + self.state_: Dict[str, Any] = dict() - def put(self, key: Text, value: Any) -> None: + def put(self, key: str, value: Any) -> None: self.state_[key] = self.marshal(value) - response = self._client.update_storage({'storage': {key: self.state_[key]}}) - if response['result'] != 'success': - raise StateHandlerError("Error updating state: {}".format(str(response))) + response = self._client.update_storage({"storage": {key: self.state_[key]}}) + if response["result"] != "success": + raise StateHandlerError(f"Error updating state: {str(response)}") - def get(self, key: Text) -> Any: + def get(self, key: str) -> Any: if key in self.state_: return self.demarshal(self.state_[key]) - response = self._client.get_storage({'keys': [key]}) - if response['result'] != 'success': - raise KeyError('key not found: ' + key) + response = self._client.get_storage({"keys": [key]}) + if response["result"] != "success": + raise KeyError("key not found: " + key) - marshalled_value = response['storage'][key] + marshalled_value = response["storage"][key] self.state_[key] = marshalled_value return self.demarshal(marshalled_value) - def contains(self, key: Text) -> bool: + def contains(self, key: str) -> bool: return key in self.state_ + @contextmanager -def use_storage(storage: BotStorage, keys: List[Text]) -> Iterator[BotStorage]: +def use_storage(storage: BotStorage, keys: List[str]) -> Iterator[BotStorage]: # The context manager for StateHandler that minimizes the number of round-trips to the server. # It will fetch all the data using the specified keys and store them to # a CachedStorage that will not communicate with the server until manually @@ -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 @@ def react(self, message: Dict[str, Any], emoji_name: str) -> Dict[str, Any]: 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 @@ def get_config_info(self, bot_name: str, optional: bool = False) -> Dict[str, st def quit(self, message: str = "") -> None: ... + class ExternalBotHandler: def __init__( self, @@ -211,19 +222,27 @@ def __init__( 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(''' + if user_profile.get("result") == "error": + msg = user_profile.get("msg", "unknown") + print( + """ ERROR: {} - '''.format(msg)) + """.format( + msg + ) + ) sys.exit(1) self._rate_limit = RateLimit(20, 5) @@ -234,12 +253,14 @@ def __init__( self._bot_config_parser = bot_config_parser self._storage = StateHandler(client) try: - self.user_id = user_profile['user_id'] - self.full_name = user_profile['full_name'] - self.email = user_profile['email'] + self.user_id = user_profile["user_id"] + 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,34 +271,40 @@ def identity(self) -> BotIdentity: 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(): self._rate_limit.show_error_and_exit() resp = self._client.send_message(message) - if resp.get('result') == 'error': + if resp.get("result") == "error": print("ERROR!: " + str(resp)) return resp - 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, - )) + 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, + ) + ) 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 @@ def get_config_info(self, bot_name: str, optional: bool = False) -> Dict[str, st 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 @@ def get_config_info(self, bot_name: str, optional: bool = False) -> Dict[str, st 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 @@ -329,7 +360,7 @@ def get_config_info(self, bot_name: str, optional: bool = False) -> Dict[str, st return dict(config_parser.items(bot_name)) def upload_file_from_path(self, file_path: str) -> Dict[str, Any]: - with open(file_path, 'rb') as file: + with open(file_path, "rb") as file: return self.upload_file(file) def upload_file(self, file: IO[Any]) -> Dict[str, Any]: @@ -343,8 +374,10 @@ def open(self, filepath: str) -> IO[str]: 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) @@ -355,21 +388,23 @@ def extract_query_without_mention(message: Dict[str, Any], client: BotHandler) - If the bot is the first @mention in the message, then this function returns the stripped message with the bot's @mention removed. Otherwise, it returns None. """ - content = message['content'] - mention = '@**' + client.full_name + '**' - extended_mention_regex = re.compile(r'^@\*\*.*\|' + str(client.user_id) + r'\*\*') + content = message["content"] + mention = "@**" + client.full_name + "**" + extended_mention_regex = re.compile(r"^@\*\*.*\|" + str(client.user_id) + r"\*\*") 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. @@ -377,25 +412,27 @@ def is_private_message_but_not_group_pm(message_dict: Dict[str, Any], current_us zulip/zulip project, so refactor with care. See the comments in extract_query_without_mention. """ - if not message_dict['type'] == 'private': + 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']] + 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"] + ] return len(recipients) == 1 and not is_message_from_self def display_config_file_errors(error_msg: str, config_file: str) -> None: file_contents = open(config_file).read() - print('\nERROR: {} seems to be broken:\n\n{}'.format(config_file, file_contents)) - print('\nMore details here:\n\n{}\n'.format(error_msg)) + print(f"\nERROR: {config_file} seems to be broken:\n\n{file_contents}") + print(f"\nMore details here:\n\n{error_msg}\n") def prepare_message_handler(bot: str, bot_handler: BotHandler, bot_lib_module: Any) -> Any: message_handler = bot_lib_module.handler_class() - if hasattr(message_handler, 'validate_config'): + if hasattr(message_handler, "validate_config"): config_data = bot_handler.get_config_info(bot) bot_lib_module.handler_class.validate_config(config_data) - if hasattr(message_handler, 'initialize'): + if hasattr(message_handler, "initialize"): message_handler.initialize(bot_handler=bot_handler) return message_handler @@ -416,13 +453,13 @@ def run_message_handler_for_bot( Set default bot_details, then override from class, if provided """ bot_details = { - 'name': bot_name.capitalize(), - 'description': "", + "name": bot_name.capitalize(), + "description": "", } - bot_details.update(getattr(lib_module.handler_class, 'META', {})) + bot_details.update(getattr(lib_module.handler_class, "META", {})) # Make sure you set up your ~/.zuliprc - client_name = "Zulip{}Bot".format(bot_name.capitalize()) + client_name = f"Zulip{bot_name.capitalize()}Bot" try: client = Client(config_file=config_file, client=client_name) @@ -436,43 +473,42 @@ def run_message_handler_for_bot( message_handler = prepare_message_handler(bot_name, restricted_client, lib_module) if not quiet: - print("Running {} Bot:".format(bot_details['name'])) - if bot_details['description'] != "": - print("\n\t{}".format(bot_details['description'])) - if hasattr(message_handler, 'usage'): + print("Running {} Bot:".format(bot_details["name"])) + if bot_details["description"] != "": + print("\n\t{}".format(bot_details["description"])) + if hasattr(message_handler, "usage"): print(message_handler.usage()) else: - print('WARNING: {} is missing usage handler, please add one eventually'.format(bot_name)) + print(f"WARNING: {bot_name} is missing usage handler, please add one eventually") def handle_message(message: Dict[str, Any], flags: List[str]) -> None: - logging.info('waiting for next message') + logging.info("waiting for next message") # `mentioned` will be in `flags` if the bot is mentioned at ANY position # (not necessarily the first @mention in the message). - is_mentioned = 'mentioned' in flags + is_mentioned = "mentioned" in flags is_private_message = is_private_message_but_not_group_pm(message, restricted_client) # Provide bots with a way to access the full, unstripped message - message['full_content'] = message['content'] + message["full_content"] = message["content"] # Strip at-mention botname from the message 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) - if message['content'] is None: + 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) - logging.info('starting message handling...') + logging.info("starting message handling...") def event_callback(event: Dict[str, Any]) -> None: - if event['type'] == 'message': - handle_message(event['message'], event['flags']) + if event["type"] == "message": + handle_message(event["message"], event["flags"]) - client.call_on_each_event(event_callback, ['message']) + client.call_on_each_event(event_callback, ["message"]) diff --git a/zulip_bots/zulip_bots/provision.py b/zulip_bots/zulip_bots/provision.py index ac840089d7..d2cde0ee03 100755 --- a/zulip_bots/zulip_bots/provision.py +++ b/zulip_bots/zulip_bots/provision.py @@ -1,36 +1,38 @@ #!/usr/bin/env python3 import argparse +import glob import logging import os -import sys import subprocess -import glob +import sys from typing import Iterator + def get_bot_paths() -> Iterator[str]: current_dir = os.path.dirname(os.path.abspath(__file__)) bots_dir = os.path.join(current_dir, "bots") - bots_subdirs = map(lambda d: os.path.abspath(d), glob.glob(bots_dir + '/*')) + bots_subdirs = map(lambda d: os.path.abspath(d), glob.glob(bots_dir + "/*")) 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') + req_path = os.path.join(path_to_bot, "requirements.txt") if os.path.isfile(req_path): bot_name = os.path.basename(path_to_bot) - logging.info('Installing dependencies for {}...'.format(bot_name)) + logging.info(f"Installing dependencies for {bot_name}...") # pip install -r $BASEDIR/requirements.txt -t $BASEDIR/bot_dependencies --quiet - rcode = subprocess.call(['pip', 'install', '-r', req_path]) + rcode = subprocess.call(["pip", "install", "-r", req_path]) if rcode != 0: - logging.error('Error. Check output of `pip install` above for details.') + logging.error("Error. Check output of `pip install` above for details.") if not force: - logging.error('Use --force to try running anyway.') + logging.error("Use --force to try running anyway.") sys.exit(rcode) # Use pip's exit code else: - logging.info('Installed dependencies successfully.') + logging.info("Installed dependencies successfully.") def parse_args(available_bots: Iterator[str]) -> argparse.Namespace: @@ -48,21 +50,24 @@ def parse_args(available_bots: Iterator[str]) -> argparse.Namespace: """ 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('--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( + "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( + "--quiet", "-q", action="store_true", default=False, help="Turn off logging output." + ) return parser.parse_args() @@ -76,5 +81,6 @@ def main() -> None: for bot in options.bots_to_provision: provision_bot(bot, options.force) -if __name__ == '__main__': + +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 1a7f7e2137..a66bc672b9 100644 --- a/zulip_bots/zulip_bots/request_test_lib.py +++ b/zulip_bots/zulip_bots/request_test_lib.py @@ -1,10 +1,10 @@ import json -import requests - from contextlib import contextmanager +from typing import Any, Dict, List from unittest.mock import patch -from typing import Any, Dict, List +import requests + @contextmanager def mock_http_conversation(http_data: Dict[str, Any]) -> Any: @@ -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. """ @@ -26,10 +29,12 @@ def get_response(http_response: Dict[str, Any], http_headers: Dict[str, Any], is mock_result._content = http_response.encode() # type: ignore # This modifies a "hidden" attribute. else: mock_result._content = json.dumps(http_response).encode() - mock_result.status_code = http_headers.get('status', 200) + 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 @@ -41,68 +46,55 @@ def assert_called_with_fields(mock_result: Any, http_request: Dict[str, Any], fi if field in http_request: args[field] = http_request[field] - mock_result.assert_called_with(http_request['api_url'], **args) + mock_result.assert_called_with(http_request["api_url"], **args) try: - http_request = http_data['request'] - http_response = http_data['response'] - http_headers = http_data['response-headers'] + http_request = http_data["request"] + http_response = http_data["response"] + http_headers = http_data["response-headers"] except KeyError: print("ERROR: Failed to find 'request', 'response' or 'response-headers' fields in fixture") raise - meta = http_data.get('meta', dict()) - is_raw_response = meta.get('is_raw_response', False) + meta = http_data.get("meta", dict()) + is_raw_response = meta.get("is_raw_response", False) - http_method = http_request.get('method', 'GET') + http_method = http_request.get("method", "GET") - if http_method == 'GET': - with patch('requests.get') as mock_get: + if http_method == "GET": + 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 - ) - elif http_method == 'PATCH': - with patch('requests.patch') as mock_patch: + 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: + 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: + 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: assert mock_result.called - with patch('requests.get') as mock_get: + with patch("requests.get") as mock_get: mock_get.return_value = True mock_get.side_effect = requests.exceptions.RequestException yield diff --git a/zulip_bots/zulip_bots/run.py b/zulip_bots/zulip_bots/run.py index c67f591d1b..6d8bf7607e 100755 --- a/zulip_bots/zulip_bots/run.py +++ b/zulip_bots/zulip_bots/run.py @@ -1,52 +1,54 @@ #!/usr/bin/env python3 -import logging import argparse -import sys +import logging import os +import sys +from typing import Optional +from zulip_bots import finder from zulip_bots.lib import ( - zulip_env_vars_are_present, - run_message_handler_for_bot, NoBotConfigException, + run_message_handler_for_bot, + zulip_env_vars_are_present, ) -from zulip_bots import finder from zulip_bots.provision import provision_bot -from typing import Optional - current_dir = os.path.dirname(os.path.abspath(__file__)) + def parse_args() -> argparse.Namespace: - usage = ''' + usage = """ zulip-run-bot --config-file ~/zuliprc zulip-run-bot --help - ''' + """ 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 @@ -61,28 +63,31 @@ def exit_gracefully_if_zulip_config_is_missing(config_file: Optional[str]) -> No # but we'll catch those later. return else: - error_msg = 'ERROR: %s does not exist.' % (config_file,) + error_msg = f"ERROR: {config_file} does not exist." else: if zulip_env_vars_are_present(): return else: - error_msg = 'ERROR: You did not supply a Zulip config file.' + error_msg = "ERROR: You did not supply a Zulip config file." if error_msg: - print('\n') + 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 @@ -90,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) @@ -116,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: @@ -149,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__': + +if __name__ == "__main__": main() diff --git a/zulip_bots/zulip_bots/simple_lib.py b/zulip_bots/zulip_bots/simple_lib.py index 96788a7ff7..24c2c7606e 100644 --- a/zulip_bots/zulip_bots/simple_lib.py +++ b/zulip_bots/zulip_bots/simple_lib.py @@ -1,15 +1,16 @@ import configparser import sys +from uuid import uuid4 from zulip_bots.lib import BotIdentity -from uuid import uuid4 + class SimpleStorage: def __init__(self): 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 @@ -17,6 +18,7 @@ def put(self, key, value): def get(self, key): return self.data[key] + class MockMessageServer: # This class is needed for the incrementor bot, which # actually updates messages! @@ -26,18 +28,19 @@ def __init__(self): def send(self, message): self.message_id += 1 - message['id'] = self.message_id + message["id"] = self.message_id self.messages[self.message_id] = message 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=f"https://server/messages/{uuid4()}/reactions") def update(self, message): - self.messages[message['message_id']] = message + self.messages[message["message_id"]] = message def upload_file(self, file): - return dict(result='success', msg='', uri='https://server/user_uploads/{}'.format(uuid4())) + return dict(result="success", msg="", uri=f"https://server/user_uploads/{uuid4()}") + class TerminalBotHandler: def __init__(self, bot_config_file, message_server): @@ -57,24 +60,32 @@ def react(self, message, emoji_name): 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(''' + if message["type"] == "stream": + 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) @@ -83,10 +94,12 @@ def send_reply(self, message, response): """ 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): @@ -95,10 +108,14 @@ def update_message(self, message): 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: @@ -112,7 +129,7 @@ def get_config_info(self, bot_name, optional=False): if optional: return dict() else: - print('Please supply --bot-config-file argument.') + print("Please supply --bot-config-file argument.") sys.exit(1) config = configparser.ConfigParser() diff --git a/zulip_bots/zulip_bots/terminal.py b/zulip_bots/zulip_bots/terminal.py index abc8be4718..7eca1c8d18 100644 --- a/zulip_bots/zulip_bots/terminal.py +++ b/zulip_bots/zulip_bots/terminal.py @@ -1,33 +1,37 @@ #!/usr/bin/env python3 +import argparse import os import sys -import argparse from zulip_bots.finder import import_module_from_source, resolve_bot_path from zulip_bots.simple_lib import MockMessageServer, TerminalBotHandler current_dir = os.path.dirname(os.path.abspath(__file__)) + def parse_args(): - description = ''' + description = """ This tool allows you to test a bot using the terminal (and no Zulip server). 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() @@ -40,7 +44,7 @@ def main(): if lib_module is None: raise OSError except OSError: - print("Could not find and import bot '{}'".format(bot_name)) + print(f"Could not find and import bot '{bot_name}'") sys.exit(1) try: @@ -51,29 +55,34 @@ def main(): message_server = MockMessageServer() bot_handler = TerminalBotHandler(args.bot_config_file, message_server) - if hasattr(message_handler, 'initialize') and callable(message_handler.initialize): + if hasattr(message_handler, "initialize") and callable(message_handler.initialize): message_handler.initialize(bot_handler) - sender_email = 'foo_sender@zulip.com' + sender_email = "foo_sender@zulip.com" try: while True: - content = input('Enter your message: ') - - message = message_server.send(dict( - content=content, - sender_email=sender_email, - display_recipient=sender_email, - )) + content = input("Enter your message: ") + + 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__': + +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 1fb04e4afe..ff1ee1e71f 100644 --- a/zulip_bots/zulip_bots/test_file_utils.py +++ b/zulip_bots/zulip_bots/test_file_utils.py @@ -1,11 +1,9 @@ import json import os - from importlib import import_module - from typing import Any, Dict -''' +""" This module helps us find files in the bots directory. Our directory structure is currently: @@ -13,21 +11,24 @@ / .py 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 # inherit from a common prototype specifying the handle_message # function. - lib_module = import_module('zulip_bots.bots.{bot}.{bot}'.format(bot=bot_name)) # type: 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')) - http_data_path = os.path.join(base_path, '{}.json'.format(test_name)) - with open(http_data_path, encoding='utf-8') as f: + 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, f"{test_name}.json") + with open(http_data_path, encoding="utf-8") as f: content = f.read() http_data = json.loads(content) return http_data diff --git a/zulip_bots/zulip_bots/test_lib.py b/zulip_bots/zulip_bots/test_lib.py index 53e37ccc14..c377ee0506 100755 --- a/zulip_bots/zulip_bots/test_lib.py +++ b/zulip_bots/zulip_bots/test_lib.py @@ -1,33 +1,18 @@ import unittest +from typing import IO, Any, Dict, List, Optional, Tuple -from typing import List, Dict, Any, Tuple, Optional, IO - -from zulip_bots.custom_exceptions import ( - ConfigValidationError, -) - -from zulip_bots.request_test_lib import ( - mock_http_conversation, - mock_request_exception -) - -from zulip_bots.simple_lib import ( - SimpleStorage, - MockMessageServer, -) - -from zulip_bots.test_file_utils import ( - get_bot_message_handler, - read_bot_fixture_data, -) - +from zulip_bots.custom_exceptions import ConfigValidationError from zulip_bots.lib import BotIdentity +from zulip_bots.request_test_lib import mock_http_conversation, mock_request_exception +from zulip_bots.simple_lib import MockMessageServer, SimpleStorage +from zulip_bots.test_file_utils import get_bot_message_handler, read_bot_fixture_data + class StubBotHandler: def __init__(self) -> None: self.storage = SimpleStorage() - self.full_name = 'test-bot' - self.email = 'test-bot@example.com' + self.full_name = "test-bot" + self.email = "test-bot@example.com" self.user_id = 0 self.message_server = MockMessageServer() self.reset_transcript() @@ -39,16 +24,14 @@ def identity(self) -> BotIdentity: return BotIdentity(self.full_name, self.email) def send_message(self, message: Dict[str, Any]) -> Dict[str, Any]: - self.transcript.append(('send_message', message)) + 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 - ) - self.transcript.append(('send_reply', response_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) + self.transcript.append(("send_reply", response_message)) return self.message_server.send(response_message) def react(self, message: Dict[str, Any], emoji_name: str) -> Dict[str, Any]: @@ -58,7 +41,7 @@ def update_message(self, message: Dict[str, Any]) -> None: self.message_server.update(message) def upload_file_from_path(self, file_path: str) -> Dict[str, Any]: - with open(file_path, 'rb') as file: + with open(file_path, "rb") as file: return self.message_server.upload_file(file) def upload_file(self, file: IO[Any]) -> Dict[str, Any]: @@ -74,33 +57,24 @@ def get_config_info(self, bot_name: str, optional: bool = False) -> Dict[str, st 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] def ensure_unique_response(self, responses: List[Dict[str, Any]]) -> None: if not responses: - raise Exception('The bot is not responding for some reason.') + raise Exception("The bot is not responding for some reason.") if len(responses) > 1: - raise Exception('The bot is giving too many responses for some reason.') + raise Exception("The bot is giving too many responses for some reason.") class DefaultTests: - bot_name = '' + bot_name = "" def make_request_message(self, content: str) -> Dict[str, Any]: raise NotImplementedError() @@ -110,26 +84,26 @@ def get_response(self, message: Dict[str, Any]) -> Dict[str, Any]: def test_bot_usage(self) -> None: bot = get_bot_message_handler(self.bot_name) - assert bot.usage() != '' + assert bot.usage() != "" def test_bot_responds_to_empty_message(self) -> None: - message = self.make_request_message('') + message = self.make_request_message("") # get_response will fail if we don't respond at all response = self.get_response(message) # we also want a non-blank response - assert len(response['content']) >= 1 + assert len(response["content"]) >= 1 class BotTestCase(unittest.TestCase): - bot_name = '' + bot_name = "" def _get_handlers(self) -> Tuple[Any, StubBotHandler]: bot = get_bot_message_handler(self.bot_name) bot_handler = StubBotHandler() - if hasattr(bot, 'initialize'): + if hasattr(bot, "initialize"): bot.initialize(bot_handler) return bot, bot_handler @@ -147,10 +121,10 @@ def make_request_message(self, content: str) -> Dict[str, Any]: mocking/subclassing. """ message = dict( - display_recipient='foo_stream', - sender_email='foo@example.com', - sender_full_name='Foo Test User', - sender_id='123', + display_recipient="foo_stream", + sender_email="foo@example.com", + sender_full_name="Foo Test User", + sender_id="123", content=content, ) return message @@ -165,7 +139,7 @@ def get_reply_dict(self, request: str) -> Dict[str, Any]: def verify_reply(self, request: str, response: str) -> None: reply = self.get_reply_dict(request) - self.assertEqual(response, reply['content']) + self.assertEqual(response, reply["content"]) def verify_dialog(self, conversation: List[Tuple[str, str]]) -> None: # Start a new message handler for the full conversation. @@ -176,7 +150,7 @@ def verify_dialog(self, conversation: List[Tuple[str, str]]) -> None: bot_handler.reset_transcript() bot.handle_message(message, bot_handler) response = bot_handler.unique_response() - self.assertEqual(expected_response, response['content']) + self.assertEqual(expected_response, response["content"]) def validate_invalid_config(self, config_data: Dict[str, str], error_regexp: str) -> None: bot_class = type(get_bot_message_handler(self.bot_name)) @@ -196,4 +170,6 @@ def mock_request_exception(self) -> Any: 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 b4a60bfb10..6d4551bf3f 100644 --- a/zulip_bots/zulip_bots/tests/test_finder.py +++ b/zulip_bots/zulip_bots/tests/test_finder.py @@ -1,15 +1,14 @@ -from unittest import TestCase from pathlib import Path +from unittest import TestCase 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') - expected_bot_name = 'helloworld' + expected_bot_path = Path(current_directory + "/bots/helloworld/helloworld.py") + expected_bot_name = "helloworld" expected_bot_path_and_name = (expected_bot_path, expected_bot_name) - actual_bot_path_and_name = finder.resolve_bot_path('helloworld') + actual_bot_path_and_name = finder.resolve_bot_path("helloworld") self.assertEqual(expected_bot_path_and_name, actual_bot_path_and_name) diff --git a/zulip_bots/zulip_bots/tests/test_lib.py b/zulip_bots/zulip_bots/tests/test_lib.py index c42c52abb8..e60e77b593 100644 --- a/zulip_bots/zulip_bots/tests/test_lib.py +++ b/zulip_bots/zulip_bots/tests/test_lib.py @@ -1,14 +1,15 @@ +import io from unittest import TestCase -from unittest.mock import MagicMock, patch, ANY, create_autospec +from unittest.mock import ANY, MagicMock, create_autospec, patch + from zulip_bots.lib import ( ExternalBotHandler, StateHandler, - run_message_handler_for_bot, extract_query_without_mention, - is_private_message_but_not_group_pm + is_private_message_but_not_group_pm, + run_message_handler_for_bot, ) -import io class FakeClient: def __init__(self, *args, **kwargs): @@ -16,53 +17,52 @@ def __init__(self, *args, **kwargs): def get_profile(self): return dict( - user_id='alice', - full_name='Alice', - email='alice@example.com', + user_id="alice", + full_name="Alice", + email="alice@example.com", id=42, ) def update_storage(self, payload): - new_data = payload['storage'] + new_data = payload["storage"] self.storage.update(new_data) return dict( - result='success', + result="success", ) def get_storage(self, request): return dict( - result='success', + result="success", storage=self.storage, ) def send_message(self, message): return dict( - result='success', + result="success", ) def upload_file(self, file): pass + class FakeBotHandler: def usage(self): - return ''' + return """ This is a fake bot handler that is used to spec BotHandler mocks. - ''' + """ 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 @@ -72,13 +72,13 @@ def test_state_handler(self): client = FakeClient() state_handler = StateHandler(client) - state_handler.put('key', [1, 2, 3]) - val = state_handler.get('key') + state_handler.put("key", [1, 2, 3]) + val = state_handler.get("key") self.assertEqual(val, [1, 2, 3]) # force us to get non-cached values state_handler = StateHandler(client) - val = state_handler.get('key') + val = state_handler.get("key") self.assertEqual(val, [1, 2, 3]) def test_state_handler_by_mock(self): @@ -87,39 +87,40 @@ def test_state_handler_by_mock(self): state_handler = StateHandler(client) client.get_storage.assert_not_called() - client.update_storage = MagicMock(return_value=dict(result='success')) - state_handler.put('key', [1, 2, 3]) - client.update_storage.assert_called_with(dict(storage=dict(key='[1, 2, 3]'))) + client.update_storage = MagicMock(return_value=dict(result="success")) + state_handler.put("key", [1, 2, 3]) + client.update_storage.assert_called_with(dict(storage=dict(key="[1, 2, 3]"))) - val = state_handler.get('key') + val = state_handler.get("key") client.get_storage.assert_not_called() 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]'))) - val = state_handler.get('non_cached_key') - client.get_storage.assert_called_with({'keys': ['non_cached_key']}) + 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]) # value must already be cached client.get_storage = MagicMock() - val = state_handler.get('non_cached_key') + val = state_handler.get("non_cached_key") client.get_storage.assert_not_called() self.assertEqual(val, [5]) 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'} + emoji_name = "wave" + message = {"id": 10} + 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)) @@ -128,37 +129,41 @@ def test_send_reply(self): 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')] + 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", + ), + ] 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: + with patch("zulip_bots.lib.Client", new=FakeClient) as fake_client: mock_lib_module = MagicMock() # __file__ is not mocked by MagicMock(), so we assign a mock value manually. mock_lib_module.__file__ = "foo" @@ -167,34 +172,36 @@ def test_run_message_handler_for_bot(self): 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'} - test_message(original_message, {'mentioned'}) + 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() - file = io.BytesIO(b'binary') + file = io.BytesIO(b"binary") handler.upload_file(file) @@ -202,49 +209,43 @@ def test_upload_file(self): def test_upload_file_from_path(self): client, handler = self._create_client_and_handler_for_file_upload() - file = io.BytesIO(b'binary') + file = io.BytesIO(b"binary") - with patch('builtins.open', return_value=file): - handler.upload_file_from_path('file.txt') + with patch("builtins.open", return_value=file): + handler.upload_file_from_path("file.txt") client.upload_file.assert_called_once_with(file) 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"} + message = {"content": "@**Alice** Hello World"} self.assertEqual(extract_query_without_mention(message, handler), "Hello World") - message = {'content': "@**Alice|alice** Hello World"} + message = {"content": "@**Alice|alice** Hello World"} self.assertEqual(extract_query_without_mention(message, handler), "Hello World") - message = {'content': "@**Alice Renamed|alice** Hello World"} + message = {"content": "@**Alice Renamed|alice** Hello World"} self.assertEqual(extract_query_without_mention(message, handler), "Hello World") - message = {'content': "Not at start @**Alice|alice** Hello World"} + message = {"content": "Not at start @**Alice|alice** Hello World"} self.assertEqual(extract_query_without_mention(message, handler), None) 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' - message['type'] = 'stream' + message["display_recipient"] = "some stream" + message["type"] = "stream" self.assertFalse(is_private_message_but_not_group_pm(message, handler)) - message['type'] = 'private' - message['display_recipient'] = [{'email': 'a1@b.com'}] - message['sender_id'] = handler.user_id + message["type"] = "private" + message["display_recipient"] = [{"email": "a1@b.com"}] + message["sender_id"] = handler.user_id self.assertFalse(is_private_message_but_not_group_pm(message, handler)) - message['sender_id'] = 0 # someone else + message["sender_id"] = 0 # someone else self.assertTrue(is_private_message_but_not_group_pm(message, handler)) - message['display_recipient'] = [{'email': 'a1@b.com'}, {'email': 'a2@b.com'}] + message["display_recipient"] = [{"email": "a1@b.com"}, {"email": "a2@b.com"}] self.assertFalse(is_private_message_but_not_group_pm(message, handler)) def _create_client_and_handler_for_file_upload(self): @@ -252,9 +253,6 @@ def _create_client_and_handler_for_file_upload(self): 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 973fa005de..6b233d5d3a 100644 --- a/zulip_bots/zulip_bots/tests/test_run.py +++ b/zulip_bots/zulip_bots/tests/test_run.py @@ -1,93 +1,113 @@ #!/usr/bin/env python3 -from pathlib import Path import os import sys -import zulip_bots.run -from zulip_bots.lib import extract_query_without_mention import unittest +from pathlib import Path from typing import Optional -from unittest import TestCase - -from unittest import mock +from unittest import TestCase, mock from unittest.mock import patch +import zulip_bots.run +from zulip_bots.lib import extract_query_without_mention + class TestDefaultArguments(TestCase): our_dir = os.path.dirname(__file__) - path_to_bot = os.path.abspath(os.path.join(our_dir, '../bots/giphy/giphy.py')) - - @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'): + path_to_bot = os.path.abspath(os.path.join(our_dir, "../bots/giphy/giphy.py")) + + @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: - with patch('zulip_bots.run.exit_gracefully_if_zulip_config_is_missing'): + 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: + 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_name="giphy", + config_file="/foo/bar/baz.conf", bot_config_file=None, lib_module=mock.ANY, - quiet=False) + quiet=False, + ) def test_adding_bot_parent_dir_to_sys_path_when_bot_name_specified(self) -> None: - bot_name = 'helloworld' # existing bot's name + 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) - - @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: - 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) - - 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'): + 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: + 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 + ) + + 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"): zulip_bots.run.main() sys_path = [Path(path).as_posix() for path in sys.path] self.assertIn(bot_dir_path, sys_path) - @patch('os.path.isfile', return_value=False) + @patch("os.path.isfile", return_value=False) def test_run_bot_by_module_name(self, mock_os_path_isfile: mock.Mock) -> None: - bot_module_name = 'bot.module.name' + 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('zulip_bots.run.run_message_handler_for_bot'): - with patch('zulip_bots.run.exit_gracefully_if_zulip_config_is_missing'): + 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() mock_import_module.assert_called_once_with(bot_module_name) 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)) + mock_message = {"content": message} + 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") @@ -98,5 +118,6 @@ def test_message(name: str, message: str, expected_return: Optional[str]) -> Non test_message("Max Mustermann", "@**Max Mustermann** foo", "foo") test_message(r"Max (Mustermann)#(*$&12]\]", r"@**Max (Mustermann)#(*$&12]\]** foo", "foo") -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/zulip_botserver/setup.py b/zulip_botserver/setup.py index 755451cb52..ec786a6f4a 100644 --- a/zulip_botserver/setup.py +++ b/zulip_botserver/setup.py @@ -10,52 +10,53 @@ # We should be installable with either setuptools or distutils. package_info = dict( - name='zulip_botserver', + name="zulip_botserver", version=ZULIP_BOTSERVER_VERSION, - description='Zulip\'s Flask server for running bots', + description="Zulip's Flask server for running bots", long_description=long_description, long_description_content_type="text/markdown", - author='Zulip Open Source Project', - author_email='zulip-devel@googlegroups.com', + author="Zulip Open Source Project", + author_email="zulip-devel@googlegroups.com", classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Topic :: Communications :: Chat', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Topic :: Communications :: Chat", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", ], - python_requires='>=3.6', - url='https://www.zulip.org/', + python_requires=">=3.6", + url="https://www.zulip.org/", project_urls={ "Source": "https://github.com/zulip/python-zulip-api/", "Documentation": "https://zulip.com/api", }, entry_points={ - 'console_scripts': [ - 'zulip-botserver=zulip_botserver.server:main', + "console_scripts": [ + "zulip-botserver=zulip_botserver.server:main", ], }, - test_suite='tests', - package_data={'zulip_botserver': ['py.typed']}, + test_suite="tests", + package_data={"zulip_botserver": ["py.typed"]}, ) # type: Dict[str, Any] setuptools_info = dict( install_requires=[ - 'zulip', - 'zulip_bots', - 'flask>=0.12.2', + "zulip", + "zulip_bots", + "flask>=0.12.2", ], ) try: - from setuptools import setup, find_packages + from setuptools import find_packages, setup + package_info.update(setuptools_info) - package_info['packages'] = find_packages(exclude=['tests']) + package_info["packages"] = find_packages(exclude=["tests"]) except ImportError: from distutils.core import setup @@ -67,20 +68,22 @@ def check_dependency_manually(module_name: str, version: Optional[str] = None) - 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( + f"{module_name}>={version} is not installed.", + file=sys.stderr, + ) else: - print("{name} is not installed.".format(name=module_name), file=sys.stderr) + print(f"{module_name} is not installed.", file=sys.stderr) sys.exit(1) - check_dependency_manually('zulip') - check_dependency_manually('zulip_bots') - check_dependency_manually('flask', '0.12.2') + check_dependency_manually("zulip") + check_dependency_manually("zulip_bots") + check_dependency_manually("flask", "0.12.2") - package_info['packages'] = ['zulip_botserver'] + package_info["packages"] = ["zulip_botserver"] setup(**package_info) diff --git a/zulip_botserver/tests/server_test_lib.py b/zulip_botserver/tests/server_test_lib.py index a90e02ace0..fc64d18642 100644 --- a/zulip_botserver/tests/server_test_lib.py +++ b/zulip_botserver/tests/server_test_lib.py @@ -1,19 +1,17 @@ import configparser import json -import mock +from typing import Any, Dict, List, Optional +from unittest import TestCase, mock -from typing import Any, List, Dict, Optional -from unittest import TestCase from zulip_botserver import server class BotServerTestCase(TestCase): - def setUp(self) -> None: server.app.testing = True self.app = server.app.test_client() - @mock.patch('zulip_bots.lib.ExternalBotHandler') + @mock.patch("zulip_bots.lib.ExternalBotHandler") def assert_bot_server_response( self, mock_ExternalBotHandler: mock.Mock, @@ -30,8 +28,12 @@ def assert_bot_server_response( 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 14784bf6a5..1b1b6fe024 100644 --- a/zulip_botserver/tests/test_server.py +++ b/zulip_botserver/tests/test_server.py @@ -1,138 +1,159 @@ -import mock +import json import os -from typing import Any, Dict -from zulip_bots.lib import BotHandler import unittest -from .server_test_lib import BotServerTestCase -import json from collections import OrderedDict from importlib import import_module -from types import ModuleType from pathlib import Path +from types import ModuleType +from typing import Any, Dict +from unittest import mock +from zulip_bots.lib import BotHandler from zulip_botserver import server from zulip_botserver.input_parameters import parse_args +from .server_test_lib import BotServerTestCase + class BotServerTests(BotServerTestCase): class MockMessageHandler: def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: - assert message == {'key': "test message"} + assert message == {"key": "test message"} class MockLibModule: def handler_class(self) -> Any: return BotServerTests.MockMessageHandler() def test_successful_request(self) -> None: - available_bots = ['helloworld'] + available_bots = ["helloworld"] bots_config = { - 'helloworld': { - 'email': 'helloworld-bot@zulip.com', - 'key': '123456789qwertyuiop', - 'site': 'http://localhost', - 'token': 'abcd1234', + "helloworld": { + "email": "helloworld-bot@zulip.com", + "key": "123456789qwertyuiop", + "site": "http://localhost", + "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'] + available_bots = ["helloworld", "help"] bots_config = { - 'helloworld': { - 'email': 'helloworld-bot@zulip.com', - 'key': '123456789qwertyuiop', - 'site': 'http://localhost', - 'token': 'abcd1234', + "helloworld": { + "email": "helloworld-bot@zulip.com", + "key": "123456789qwertyuiop", + "site": "http://localhost", + "token": "abcd1234", + }, + "help": { + "email": "help-bot@zulip.com", + "key": "123456789qwertyuiop", + "site": "http://localhost", + "token": "abcd1234", }, - 'help': { - 'email': 'help-bot@zulip.com', - '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 = { - 'helloworld': { - 'email': 'helloworld-bot@zulip.com', - 'key': '123456789qwertyuiop', - 'site': 'http://localhost', - 'token': 'abcd1234', + "helloworld": { + "email": "helloworld-bot@zulip.com", + "key": "123456789qwertyuiop", + "site": "http://localhost", + "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'] + available_bots = ["helloworld"] bots_config = { - 'helloworld': { - 'email': 'helloworld-bot@zulip.com', - 'key': '123456789qwertyuiop', - 'site': 'http://localhost', - 'token': 'abcd1234', + "helloworld": { + "email": "helloworld-bot@zulip.com", + "key": "123456789qwertyuiop", + "site": "http://localhost", + "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) - - @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: - available_bots = ['nonexistent-bot'] + 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: + available_bots = ["nonexistent-bot"] bots_config = { - 'nonexistent-bot': { - 'email': 'helloworld-bot@zulip.com', - 'key': '123456789qwertyuiop', - 'site': 'http://localhost', - 'token': 'abcd1234', + "nonexistent-bot": { + "email": "helloworld-bot@zulip.com", + "key": "123456789qwertyuiop", + "site": "http://localhost", + "token": "abcd1234", } } # 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']) + @mock.patch("sys.argv", ["zulip-botserver", "--config-file", "/foo/bar/baz.conf"]) def test_argument_parsing_defaults(self) -> None: opts = parse_args() - assert opts.config_file == '/foo/bar/baz.conf' + assert opts.config_file == "/foo/bar/baz.conf" assert opts.bot_name is None assert opts.bot_config_file is None - assert opts.hostname == '127.0.0.1' + assert opts.hostname == "127.0.0.1" assert opts.port == 5002 def test_read_config_from_env_vars(self) -> None: @@ -140,28 +161,30 @@ def test_read_config_from_env_vars(self) -> None: # the stringified environment variable is standard even on # Python 3.7 and earlier. bots_config = OrderedDict() - bots_config['hello_world'] = { - 'email': 'helloworld-bot@zulip.com', - 'key': 'value', - 'site': 'http://localhost', - 'token': 'abcd1234', + bots_config["hello_world"] = { + "email": "helloworld-bot@zulip.com", + "key": "value", + "site": "http://localhost", + "token": "abcd1234", } - bots_config['giphy'] = { - 'email': 'giphy-bot@zulip.com', - 'key': 'value2', - 'site': 'http://localhost', - 'token': 'abcd1234', + bots_config["giphy"] = { + "email": "giphy-bot@zulip.com", + "key": "value2", + "site": "http://localhost", + "token": "abcd1234", } - os.environ['ZULIP_BOTSERVER_CONFIG'] = json.dumps(bots_config) + os.environ["ZULIP_BOTSERVER_CONFIG"] = json.dumps(bots_config) # No bot specified; should read all bot configs assert server.read_config_from_env_vars() == bots_config # Specified bot exists; should read only that section. - assert server.read_config_from_env_vars("giphy") == {'giphy': bots_config['giphy']} + 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): @@ -171,29 +194,29 @@ def test_read_config_file(self) -> None: # No bot specified; should read all bot configs. bot_conf1 = server.read_config_file(os.path.join(current_dir, "test.conf")) expected_config1 = { - 'helloworld': { - 'email': 'helloworld-bot@zulip.com', - 'key': 'value', - 'site': 'http://localhost', - 'token': 'abcd1234', + "helloworld": { + "email": "helloworld-bot@zulip.com", + "key": "value", + "site": "http://localhost", + "token": "abcd1234", + }, + "giphy": { + "email": "giphy-bot@zulip.com", + "key": "value2", + "site": "http://localhost", + "token": "abcd1234", }, - 'giphy': { - 'email': 'giphy-bot@zulip.com', - 'key': 'value2', - 'site': 'http://localhost', - 'token': 'abcd1234', - } } assert json.dumps(bot_conf1, sort_keys=True) == json.dumps(expected_config1, sort_keys=True) # Specified bot exists; should read only that section. bot_conf3 = server.read_config_file(os.path.join(current_dir, "test.conf"), "giphy") expected_config3 = { - 'giphy': { - 'email': 'giphy-bot@zulip.com', - 'key': 'value2', - 'site': 'http://localhost', - 'token': 'abcd1234', + "giphy": { + "email": "giphy-bot@zulip.com", + "key": "value2", + "site": "http://localhost", + "token": "abcd1234", } } assert json.dumps(bot_conf3, sort_keys=True) == json.dumps(expected_config3, sort_keys=True) @@ -201,11 +224,11 @@ def test_read_config_file(self) -> None: # Specified bot doesn't exist; should read the first section of the config. bot_conf2 = server.read_config_file(os.path.join(current_dir, "test.conf"), "redefined_bot") expected_config2 = { - 'redefined_bot': { - 'email': 'helloworld-bot@zulip.com', - 'key': 'value', - 'site': 'http://localhost', - 'token': 'abcd1234', + "redefined_bot": { + "email": "helloworld-bot@zulip.com", + "key": "value", + "site": "http://localhost", + "token": "abcd1234", } } assert json.dumps(bot_conf2, sort_keys=True) == json.dumps(expected_config2, sort_keys=True) @@ -214,31 +237,42 @@ def test_load_lib_modules(self) -> None: # This testcase requires hardcoded paths, which here is a good thing so if we ever # restructure zulip_bots, this test would fail and we would also update Botserver # at the same time. - helloworld = import_module('zulip_bots.bots.{bot}.{bot}'.format(bot='helloworld')) + helloworld = import_module("zulip_bots.bots.{bot}.{bot}".format(bot="helloworld")) root_dir = Path(__file__).parents[2].as_posix() # load valid module name - module = server.load_lib_modules(['helloworld'])['helloworld'] + module = server.load_lib_modules(["helloworld"])["helloworld"] 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.__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__': + +if __name__ == "__main__": unittest.main() diff --git a/zulip_botserver/zulip_botserver/input_parameters.py b/zulip_botserver/zulip_botserver/input_parameters.py index 1b7d585ab2..68b96275d5 100644 --- a/zulip_botserver/zulip_botserver/input_parameters.py +++ b/zulip_botserver/zulip_botserver/input_parameters.py @@ -2,48 +2,51 @@ def parse_args() -> argparse.Namespace: - usage = ''' + usage = """ zulip-botserver --config-file [--hostname=
] [--port=] - ''' + """ parser = argparse.ArgumentParser(usage=usage) 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', - action='store', - help='Config file for the Botserver. Use your `botserverrc` for multiple bots or' - '`zuliprc` for a single bot.' + "--config-file", + "-c", + action="store", + help="Config file for the Botserver. Use your `botserverrc` for multiple bots or" + "`zuliprc` for a single bot.", ) mutually_exclusive_args.add_argument( - '--use-env-vars', '-e', - action='store_true', - help='Load configuration from JSON in ZULIP_BOTSERVER_CONFIG environment variable.' + "--use-env-vars", + "-e", + action="store_true", + help="Load configuration from JSON in ZULIP_BOTSERVER_CONFIG environment variable.", ) parser.add_argument( - '--bot-config-file', - action='store', + "--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.' + help="Config file for bots. Only needed when one of " + "the bots you want to run requires a config file.", ) parser.add_argument( - '--bot-name', '-b', - action='store', - help='Run a single bot BOT_NAME. Use this option to run the Botserver ' - 'with a `zuliprc` config file.' + "--bot-name", + "-b", + action="store", + help="Run a single bot BOT_NAME. Use this option to run the Botserver " + "with a `zuliprc` config file.", ) parser.add_argument( - '--hostname', - action='store', + "--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', + "--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 b7d85e7b3d..7dc9abfacd 100644 --- a/zulip_botserver/zulip_botserver/server.py +++ b/zulip_botserver/zulip_botserver/server.py @@ -1,19 +1,19 @@ #!/usr/bin/env python3 import configparser -import logging +import importlib.abc +import importlib.util import json +import logging import os import sys -import importlib.abc -import importlib.util - from collections import OrderedDict from configparser import MissingSectionHeaderError, NoOptionError -from flask import Flask, request from importlib import import_module -from typing import Any, Dict, List, Optional from types import ModuleType +from typing import Any, Dict, List, Optional + +from flask import Flask, request from werkzeug.exceptions import BadRequest, Unauthorized from zulip import Client @@ -23,19 +23,22 @@ def read_config_section(parser: configparser.ConfigParser, section: str) -> Dict[str, str]: section_info = { - "email": parser.get(section, 'email'), - "key": parser.get(section, 'key'), - "site": parser.get(section, 'site'), - "token": parser.get(section, 'token'), + "email": parser.get(section, "email"), + "key": parser.get(section, "key"), + "site": parser.get(section, "site"), + "token": parser.get(section, "token"), } 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') + 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,12 +91,14 @@ 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:] if len(ignored_sections) > 0: - logging.warning("Sections except the '{}' will be ignored".format(bot_section)) + logging.warning(f"Sections except the '{bot_section}' will be ignored") return bots_config @@ -93,11 +106,12 @@ def read_config_file(config_file_path: str, bot_name: Optional[str] = None) -> D def parse_config_file(config_file_path: str) -> configparser.ConfigParser: config_file_path = os.path.abspath(os.path.expanduser(config_file_path)) if not os.path.isfile(config_file_path): - raise OSError("Could not read config file {}: File not found.".format(config_file_path)) + raise OSError(f"Could not read config file {config_file_path}: File not found.") parser = 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,21 +121,26 @@ 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: try: - if bot.endswith('.py') and os.path.isfile(bot): + if bot.endswith(".py") and os.path.isfile(bot): lib_module = load_module_from_file(bot) else: - module_name = 'zulip_bots.bots.{bot}.{bot}'.format(bot=bot) + module_name = "zulip_bots.bots.{bot}.{bot}".format(bot=bot) 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"]) - bot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'bots', bot) + 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 @@ -166,36 +184,40 @@ def init_message_handlers( bots_config = {} # type: Dict[str, Dict[str, str]] -@app.route('/', methods=['POST']) +@app.route("/", methods=["POST"]) def handle_bot() -> str: event = request.get_json(force=True) assert event is not None for bot_name, config in bots_config.items(): - if config['email'] == event['bot_email']: + if config["email"] == event["bot_email"]: bot = bot_name 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'])) - 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 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"]) + ) app.config.get("BOTS_LIB_MODULES", {})[bot] bot_handler = app.config.get("BOT_HANDLERS", {})[bot] message_handler = app.config.get("MESSAGE_HANDLERS", {})[bot] - is_mentioned = event['trigger'] == "mention" - is_private_message = event['trigger'] == "private_message" + is_mentioned = event["trigger"] == "mention" + is_private_message = event["trigger"] == "private_message" message = event["message"] - message['full_content'] = message['content'] + message["full_content"] = message["content"] # Strip at-mention botname from the message 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'] = lib.extract_query_without_mention(message=message, client=bot_handler) - if message['content'] is None: + message["content"] = lib.extract_query_without_mention(message=message, client=bot_handler) + if message["content"] is None: return json.dumps(dict(response_not_required=True)) if is_private_message or is_mentioned: @@ -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__': + +if __name__ == "__main__": main()