From 8e64ddb0ce810711a643175e007cf2be7789df30 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 13 Dec 2023 10:21:12 -0500 Subject: [PATCH] feat: implement basic hook logic (#4) * Add logic and tests --------- Co-authored-by: Kazuhiro Sera Co-authored-by: Fil Maj --- .github/workflows/tests.yml | 2 +- README.md | 156 +++++++++++++++++- requirements/format.txt | 3 +- requirements/testing.txt | 3 + slack_cli_hooks/__init__.py | 7 + slack_cli_hooks/error/__init__.py | 2 + slack_cli_hooks/hooks/__init__.py | 2 + slack_cli_hooks/hooks/get_hooks.py | 23 +++ slack_cli_hooks/hooks/get_manifest.py | 50 ++++++ slack_cli_hooks/hooks/start.py | 64 +++++++ slack_cli_hooks/hooks/utils/__init__.py | 5 + .../hooks/utils/managed_os_env_vars.py | 22 +++ slack_cli_hooks/protocol/__init__.py | 24 +++ slack_cli_hooks/protocol/default_protocol.py | 24 +++ .../protocol/message_boundary_protocol.py | 24 +++ slack_cli_hooks/protocol/protocol.py | 44 +++++ slack_cli_hooks/py.typed | 0 tests/mock_socket_mode_server.py | 52 ++++++ tests/mock_web_api_server.py | 82 +++++++++ tests/scenario_test/__init__.py | 0 tests/scenario_test/test_app/__init__.py | 0 tests/scenario_test/test_app/app.py | 27 +++ tests/scenario_test/test_app/manifest.json | 6 + tests/scenario_test/test_app/my_app.py | 18 ++ tests/scenario_test/test_app/utils.py | 23 +++ .../test_app_no_manifest/no_app_here.py | 0 tests/scenario_test/test_get_hooks.py | 24 +++ tests/scenario_test/test_get_manifest.py | 33 ++++ tests/scenario_test/test_start.py | 60 +++++++ tests/slack_cli_hooks/__init__.py | 0 tests/slack_cli_hooks/hooks/__init__.py | 0 tests/slack_cli_hooks/hooks/test_get_hooks.py | 24 +++ .../hooks/test_get_manifest.py | 47 ++++++ tests/slack_cli_hooks/hooks/test_start.py | 55 ++++++ tests/slack_cli_hooks/hooks/utils/__init__.py | 0 .../hooks/utils/test_managed_os_env_vars.py | 76 +++++++++ tests/slack_cli_hooks/protocol/__init__.py | 0 .../protocol/test_default_protocol.py | 41 +++++ .../protocol/test_mssage_boundary_protocol.py | 56 +++++++ .../protocol/test_protocol_factory.py | 15 ++ tests/test_dummy.py | 9 - tests/utils.py | 11 ++ 42 files changed, 1100 insertions(+), 14 deletions(-) create mode 100644 slack_cli_hooks/error/__init__.py create mode 100644 slack_cli_hooks/hooks/__init__.py create mode 100644 slack_cli_hooks/hooks/get_hooks.py create mode 100644 slack_cli_hooks/hooks/get_manifest.py create mode 100644 slack_cli_hooks/hooks/start.py create mode 100644 slack_cli_hooks/hooks/utils/__init__.py create mode 100644 slack_cli_hooks/hooks/utils/managed_os_env_vars.py create mode 100644 slack_cli_hooks/protocol/__init__.py create mode 100644 slack_cli_hooks/protocol/default_protocol.py create mode 100644 slack_cli_hooks/protocol/message_boundary_protocol.py create mode 100644 slack_cli_hooks/protocol/protocol.py create mode 100644 slack_cli_hooks/py.typed create mode 100644 tests/mock_socket_mode_server.py create mode 100644 tests/mock_web_api_server.py create mode 100644 tests/scenario_test/__init__.py create mode 100644 tests/scenario_test/test_app/__init__.py create mode 100644 tests/scenario_test/test_app/app.py create mode 100644 tests/scenario_test/test_app/manifest.json create mode 100644 tests/scenario_test/test_app/my_app.py create mode 100644 tests/scenario_test/test_app/utils.py create mode 100644 tests/scenario_test/test_app_no_manifest/no_app_here.py create mode 100644 tests/scenario_test/test_get_hooks.py create mode 100644 tests/scenario_test/test_get_manifest.py create mode 100644 tests/scenario_test/test_start.py create mode 100644 tests/slack_cli_hooks/__init__.py create mode 100644 tests/slack_cli_hooks/hooks/__init__.py create mode 100644 tests/slack_cli_hooks/hooks/test_get_hooks.py create mode 100644 tests/slack_cli_hooks/hooks/test_get_manifest.py create mode 100644 tests/slack_cli_hooks/hooks/test_start.py create mode 100644 tests/slack_cli_hooks/hooks/utils/__init__.py create mode 100644 tests/slack_cli_hooks/hooks/utils/test_managed_os_env_vars.py create mode 100644 tests/slack_cli_hooks/protocol/__init__.py create mode 100644 tests/slack_cli_hooks/protocol/test_default_protocol.py create mode 100644 tests/slack_cli_hooks/protocol/test_mssage_boundary_protocol.py create mode 100644 tests/slack_cli_hooks/protocol/test_protocol_factory.py delete mode 100644 tests/test_dummy.py create mode 100644 tests/utils.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index caa693e..671a84c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: | pip install -U pip - pip install . + pip install -r requirements.txt pip install -r requirements/testing.txt - name: Run tests run: | diff --git a/README.md b/README.md index 285a3bd..6b3164c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,154 @@ -# python-slack-hooks -Helper library implementing the contract between the Slack CLI and Bolt for Python +

Python Slack Hooks

+ +A helper library implementing the contract between the +[Slack CLI][slack-cli-docs] and +[Bolt for Python](https://slack.dev/bolt-python/) + +## Environment requirements + +Before getting started, make sure you have a development workspace where you +have permissions to install apps. **Please note that leveraging all features in +this project require that the workspace be part of +[a Slack paid plan](https://slack.com/pricing).** + +### Install the Slack CLI + +Install the Slack CLI. Step-by-step instructions can be found in this +[Quickstart Guide][slack-cli-docs]. + +### Environment Setup + +Create a project folder and a +[virtual environment](https://docs.python.org/3/library/venv.html#module-venv) +within it + +```zsh +# Python 3.6+ required +mkdir myproject +cd myproject +python3 -m venv .venv +``` + +Activate the environment + +```zsh +source .venv/bin/activate +``` + +### Pypi + +Install this package using pip. + +```zsh +pip install -U slack-cli-hooks +``` + +### Clone + +Clone this project using git. + +```zsh +git clone https://github.com/slackapi/python-slack-hooks.git +``` + +Follow the +[Develop Locally](https://github.com/slackapi/python-slack-hooks/blob/main/.github/maintainers_guide.md#develop-locally) +steps in the maintainers guide to build and use this package. + +## Simple project + +In the same directory where we installed `slack-cli-hooks` + +1. Define basic information and metadata about our app via an + [App Manifest](https://api.slack.com/reference/manifests) (`manifest.json`). +2. Create a `slack.json` file that defines the interface between the + [Slack CLI][slack-cli-docs] and [Bolt for Python][bolt-python-docs]. +3. Use an `app.py` file to define the entrypoint for a + [Bolt for Python][bolt-python-docs] project. + +### Application Configuration + +Define your [Application Manifest](https://api.slack.com/reference/manifests) in +a `manifest.json` file. + +```json +{ + "display_information": { + "name": "simple-app" + }, + "outgoing_domains": [], + "settings": { + "org_deploy_enabled": true, + "socket_mode_enabled": true, + }, + "features": { + "bot_user": { + "display_name": "simple-app" + } + }, + "oauth_config": { + "scopes": { + "bot": ["chat:write"] + } + } +} +``` + +### CLI/Bolt Interface Configuration + +Define the Slack CLI configuration in a file named `slack.json`. + +```json +{ + "hooks": { + "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks" + } +} +``` + +### Source code + +Create a [Bolt for Python][bolt-python-docs] app in a file named `app.py`. +Alternatively you can use an existing app instead. + +```python +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +app = App() + +# Add functionality here + +if __name__ == "__main__": + SocketModeHandler(app).start() +``` + +## Running the app + +You should now be able to harness the power of the Slack CLI and Bolt. + +Run the app this way: + +```zsh +slack run +``` + +## Getting Help + +If you get stuck we're here to help. Ensure your issue is related to this +project and not to [Bolt for Python][bolt-python-docs]. The following are the +best ways to get assistance working through your issue: + +- [Issue Tracker](https://github.com/slackapi/python-slack-hooks/issues) for + questions, bug reports, feature requests, and general discussion. **Try + searching for an existing issue before creating a new one.** +- Email our developer support team: `support@slack.com` + +## Contributing + +Contributions are more then welcome. Please look at the +[contributing guidelines](https://github.com/slackapi/python-slack-hooks/blob/main/.github/CONTRIBUTING.md) +for more info! + +[slack-cli-docs]: https://api.slack.com/automation/cli +[bolt-python-docs]: https://slack.dev/bolt-python/concepts diff --git a/requirements/format.txt b/requirements/format.txt index 6e78bf4..8df1850 100644 --- a/requirements/format.txt +++ b/requirements/format.txt @@ -1,6 +1,5 @@ black==22.8.0; python_version=="3.6" black; python_version>"3.6" # Until we drop Python 3.6 support, we have to stay with this version -flake8>=5.0.4; python_version=="3.6" -flake8==6.0.0; python_version>"3.6" +flake8>=5.0.4, <7; pytype; (python_version<"3.11" or python_version>"3.11") pytype==2023.11.29; python_version=="3.11" diff --git a/requirements/testing.txt b/requirements/testing.txt index d48df50..22f6998 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,3 +1,6 @@ # pip install -r requirements/testing.txt pytest>=6.2.5,<7 pytest-cov>=3,<4 +Flask>=2.0.3,<4 +gevent>=22.10.2,<24 +gevent-websocket>=0.10.1,<1 diff --git a/slack_cli_hooks/__init__.py b/slack_cli_hooks/__init__.py index e69de29..7cca419 100644 --- a/slack_cli_hooks/__init__.py +++ b/slack_cli_hooks/__init__.py @@ -0,0 +1,7 @@ +""" +A Slack CLI hooks implementation in Python to build Bolt Slack apps leveraging the full power of the [Slack CLI](https://api.slack.com/automation/cli/install). Look at our [code examples](https://github.com/slackapi/python-slack-hooks/tree/main/examples) to learn how to build apps using the SLack CLI and Bolt. + +* Slack CLI: https://api.slack.com/automation/cli/install +* Bolt Website: https://slack.dev/bolt-python/ +* GitHub repository: https://github.com/slackapi/python-slack-hooks +""" # noqa: E501 diff --git a/slack_cli_hooks/error/__init__.py b/slack_cli_hooks/error/__init__.py new file mode 100644 index 0000000..570528a --- /dev/null +++ b/slack_cli_hooks/error/__init__.py @@ -0,0 +1,2 @@ +class CliError(Exception): + """General class for cli error""" diff --git a/slack_cli_hooks/hooks/__init__.py b/slack_cli_hooks/hooks/__init__.py new file mode 100644 index 0000000..2ec8be8 --- /dev/null +++ b/slack_cli_hooks/hooks/__init__.py @@ -0,0 +1,2 @@ +"""Slack CLI contract implementation for Bolt. +""" diff --git a/slack_cli_hooks/hooks/get_hooks.py b/slack_cli_hooks/hooks/get_hooks.py new file mode 100644 index 0000000..df0a36a --- /dev/null +++ b/slack_cli_hooks/hooks/get_hooks.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +import json +from slack_cli_hooks.protocol import Protocol, MessageBoundaryProtocol, DefaultProtocol, build_protocol + +PROTOCOL: Protocol +EXEC = "python3" + + +hooks_payload = { + "hooks": { + "get-manifest": f"{EXEC} -m slack_cli_hooks.hooks.get_manifest", + "start": f"{EXEC} -X dev -m slack_cli_hooks.hooks.start", + }, + "config": { + "watch": {"filter-regex": "(^manifest\\.json$)", "paths": ["."]}, + "protocol-version": [MessageBoundaryProtocol.name, DefaultProtocol.name], + "sdk-managed-connection-enabled": True, + }, +} + +if __name__ == "__main__": + PROTOCOL = build_protocol() + PROTOCOL.respond(json.dumps(hooks_payload)) diff --git a/slack_cli_hooks/hooks/get_manifest.py b/slack_cli_hooks/hooks/get_manifest.py new file mode 100644 index 0000000..79a5caa --- /dev/null +++ b/slack_cli_hooks/hooks/get_manifest.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +import os +import re +from typing import List + +from slack_cli_hooks.error import CliError +from slack_cli_hooks.protocol import Protocol, build_protocol + +PROTOCOL: Protocol + +EXCLUDED_DIRECTORIES = [ + "lib", + "bin", + "include", + "node_modules", + "packages", + "logs", + "build", + "coverage", + "target", + "tmp", + "test", + "tests", +] + +DIRECTORY_IGNORE_REGEX = re.compile(r"(^\.|^\_|^{}$)".format("$|^".join(EXCLUDED_DIRECTORIES)), re.IGNORECASE) + + +def filter_directories(directories: List[str]) -> List[str]: + return [directory for directory in directories if not DIRECTORY_IGNORE_REGEX.match(directory)] + + +def find_file_path(path: str, file_name: str) -> str: + for root, dirs, files in os.walk(path, topdown=True, followlinks=False): + dirs[:] = filter_directories(dirs) + if file_name in files: + return os.path.join(root, file_name) + raise CliError(f"Could not find a {file_name} file") + + +def get_manifest(working_directory: str) -> str: + file_path = find_file_path(working_directory, "manifest.json") + + with open(file_path, "r") as manifest: + return manifest.read() + + +if __name__ == "__main__": + PROTOCOL = build_protocol() + PROTOCOL.respond(get_manifest(os.getcwd())) diff --git a/slack_cli_hooks/hooks/start.py b/slack_cli_hooks/hooks/start.py new file mode 100644 index 0000000..2866d25 --- /dev/null +++ b/slack_cli_hooks/hooks/start.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +import os +import runpy +import sys + +from slack_cli_hooks.error import CliError +from slack_cli_hooks.hooks.utils import ManagedOSEnvVars +from slack_cli_hooks.protocol import Protocol, build_protocol + +PROTOCOL: Protocol + +DEFAULT_MAIN_FILE = "app.py" + +SLACK_CLI_XOXB = "SLACK_CLI_XOXB" +SLACK_CLI_XAPP = "SLACK_CLI_XAPP" +SLACK_BOT_TOKEN = "SLACK_BOT_TOKEN" +SLACK_APP_TOKEN = "SLACK_APP_TOKEN" + + +def validate_env() -> None: + if not os.environ.get(SLACK_CLI_XOXB): + raise CliError(f"Missing local run bot token ({SLACK_CLI_XOXB}).") + if not os.environ.get(SLACK_CLI_XAPP): + raise CliError(f"Missing local run app token ({SLACK_CLI_XAPP}).") + + +def get_main_file() -> str: + custom_file = os.environ.get("SLACK_APP_PATH") + if custom_file: + return custom_file + return DEFAULT_MAIN_FILE + + +def get_main_path(working_directory: str) -> str: + main_file = get_main_file() + main_raw_path = os.path.join(working_directory, main_file) + return os.path.abspath(main_raw_path) + + +def start(working_directory: str) -> None: + validate_env() + + entrypoint_path = get_main_path(working_directory) + + if not os.path.exists(entrypoint_path): + raise CliError(f"Could not find {get_main_file()} file") + + parent_package = os.path.dirname(entrypoint_path) + os_env_vars = ManagedOSEnvVars(PROTOCOL) + + try: + os_env_vars.set_if_absent(SLACK_BOT_TOKEN, os.environ[SLACK_CLI_XOXB]) + os_env_vars.set_if_absent(SLACK_APP_TOKEN, os.environ[SLACK_CLI_XAPP]) + sys.path.insert(0, parent_package) # Add parent package to sys path + + runpy.run_path(entrypoint_path, run_name="__main__") + finally: + sys.path.remove(parent_package) + os_env_vars.clear() + + +if __name__ == "__main__": + PROTOCOL = build_protocol() + start(os.getcwd()) diff --git a/slack_cli_hooks/hooks/utils/__init__.py b/slack_cli_hooks/hooks/utils/__init__.py new file mode 100644 index 0000000..4515687 --- /dev/null +++ b/slack_cli_hooks/hooks/utils/__init__.py @@ -0,0 +1,5 @@ +from .managed_os_env_vars import ManagedOSEnvVars + +__all__ = [ + "ManagedOSEnvVars", +] diff --git a/slack_cli_hooks/hooks/utils/managed_os_env_vars.py b/slack_cli_hooks/hooks/utils/managed_os_env_vars.py new file mode 100644 index 0000000..8cd3f75 --- /dev/null +++ b/slack_cli_hooks/hooks/utils/managed_os_env_vars.py @@ -0,0 +1,22 @@ +import os +from typing import List +from slack_cli_hooks.protocol import Protocol + + +class ManagedOSEnvVars: + def __init__(self, protocol: Protocol) -> None: + self._protocol = protocol + self._os_env_vars: List[str] = [] + + def set_if_absent(self, os_env_var: str, value: str) -> None: + if os_env_var in os.environ: + self._protocol.info( + f"{os_env_var} environment variable detected in session, using it over the provided one!" + ) + return + self._os_env_vars.append(os_env_var) + os.environ[os_env_var] = value + + def clear(self) -> None: + for os_env_var in self._os_env_vars: + os.environ.pop(os_env_var, None) diff --git a/slack_cli_hooks/protocol/__init__.py b/slack_cli_hooks/protocol/__init__.py new file mode 100644 index 0000000..f24ce45 --- /dev/null +++ b/slack_cli_hooks/protocol/__init__.py @@ -0,0 +1,24 @@ +import argparse +import sys +from typing import List +from .default_protocol import DefaultProtocol +from .message_boundary_protocol import MessageBoundaryProtocol +from .protocol import Protocol + +__all__ = [ + "DefaultProtocol", + "MessageBoundaryProtocol", + "Protocol", +] + + +def build_protocol(argv: List[str] = sys.argv[1:]) -> Protocol: + parser = argparse.ArgumentParser() + parser.add_argument("--protocol", type=str, required=False) + parser.add_argument("--boundary", type=str, required=False) + + args, unknown = parser.parse_known_args(args=argv) + + if args.protocol == MessageBoundaryProtocol.name: + return MessageBoundaryProtocol(boundary=args.boundary) + return DefaultProtocol() diff --git a/slack_cli_hooks/protocol/default_protocol.py b/slack_cli_hooks/protocol/default_protocol.py new file mode 100644 index 0000000..d1911dd --- /dev/null +++ b/slack_cli_hooks/protocol/default_protocol.py @@ -0,0 +1,24 @@ +from .protocol import Protocol + + +class DefaultProtocol(Protocol): + name: str = "default" + + def debug(self, msg: str, *args, **kwargs): + """Nothing will be logged here""" + pass + + def info(self, msg: str, *args, **kwargs): + """Nothing will be logged here""" + pass + + def warning(self, msg: str, *args, **kwargs): + """Nothing will be logged here""" + pass + + def error(self, msg: str, *args, **kwargs): + """Nothing will be logged here""" + pass + + def respond(self, data: str): + print(data) diff --git a/slack_cli_hooks/protocol/message_boundary_protocol.py b/slack_cli_hooks/protocol/message_boundary_protocol.py new file mode 100644 index 0000000..db987e4 --- /dev/null +++ b/slack_cli_hooks/protocol/message_boundary_protocol.py @@ -0,0 +1,24 @@ +import logging +from .protocol import Protocol + + +class MessageBoundaryProtocol(Protocol): + name: str = "message-boundaries" + + def __init__(self, boundary): + self.boundary = boundary + + def debug(self, msg: str, *args, **kwargs): + logging.debug(msg, *args, **kwargs) + + def info(self, msg: str, *args, **kwargs): + logging.info(msg, *args, **kwargs) + + def warning(self, msg: str, *args, **kwargs): + logging.warning(msg, *args, **kwargs) + + def error(self, msg: str, *args, **kwargs): + logging.error(msg, *args, **kwargs) + + def respond(self, data: str): + print(self.boundary + data + self.boundary) diff --git a/slack_cli_hooks/protocol/protocol.py b/slack_cli_hooks/protocol/protocol.py new file mode 100644 index 0000000..ae69b0d --- /dev/null +++ b/slack_cli_hooks/protocol/protocol.py @@ -0,0 +1,44 @@ +import abc + + +class Protocol(metaclass=abc.ABCMeta): + @classmethod + def __subclasshook__(cls, subclass): + return ( + hasattr(subclass, "name") + and hasattr(subclass, "debug") + and callable(subclass.debug) + and hasattr(subclass, "info") + and callable(subclass.info) + and hasattr(subclass, "warning") + and callable(subclass.warning) + and hasattr(subclass, "error") + and callable(subclass.error) + and hasattr(subclass, "respond") + and callable(subclass.respond) + ) + + @abc.abstractmethod + def debug(self, msg: str, *args, **kwargs): + """Logs a message with level DEBUG""" + raise NotImplementedError + + @abc.abstractmethod + def info(self, msg: str, *args, **kwargs): + """Logs a message with level INFO""" + raise NotImplementedError + + @abc.abstractmethod + def warning(self, msg: str, *args, **kwargs): + """Logs a message with level WARNING""" + raise NotImplementedError + + @abc.abstractmethod + def error(self, msg: str, *args, **kwargs): + """Logs a message with level ERROR on the root logger""" + raise NotImplementedError + + @abc.abstractmethod + def respond(self, data: str): + """Utility method for responding to CLI hook invocations""" + raise NotImplementedError diff --git a/slack_cli_hooks/py.typed b/slack_cli_hooks/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_socket_mode_server.py b/tests/mock_socket_mode_server.py new file mode 100644 index 0000000..c3b1703 --- /dev/null +++ b/tests/mock_socket_mode_server.py @@ -0,0 +1,52 @@ +import json +import threading +import time +from urllib.request import urlopen +from urllib.error import URLError +from unittest import TestCase +from flask import Flask +from gevent import pywsgi +from geventwebsocket.handler import WebSocketHandler + + +def start_thread_socket_mode_server(test: TestCase, port: int): + def _start_thread_socket_mode_server(): + app: Flask = Flask(__name__) + + @app.route("/state") + def state(): + return json.dumps({"success": True}), 200, {"ContentType": "application/json"} + + server = pywsgi.WSGIServer(("", port), app, handler_class=WebSocketHandler) + test.server = server + server.serve_forever(stop_timeout=1) + + return _start_thread_socket_mode_server + + +def start_socket_mode_server(test, port: int): + test.sm_thread = threading.Thread(target=start_thread_socket_mode_server(test, port)) + test.sm_thread.daemon = True + test.sm_thread.start() + wait_for_socket_mode_server(port, 4) # wait for the server + + +def wait_for_socket_mode_server(port: int, secs: int): + start_time = time.time() + while (time.time() - start_time) < secs: + try: + urlopen(f"http://localhost:{port}/state") + break + except URLError: + pass + + +def stop_socket_mode_server(test): + print(test) + test.server.stop() + test.server.close() + + +async def stop_socket_mode_server_async(test: TestCase): + test.server.stop() + test.server.close() diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py new file mode 100644 index 0000000..7b81ab6 --- /dev/null +++ b/tests/mock_web_api_server.py @@ -0,0 +1,82 @@ +import json +import logging +import threading +from http import HTTPStatus +from http.server import HTTPServer, SimpleHTTPRequestHandler +from typing import Type +from unittest import TestCase +from urllib.parse import urlparse, ParseResult + + +class MockHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + default_request_version = "HTTP/1.1" + logger = logging.getLogger(__name__) + received_requests = {} + + def set_common_headers(self): + self.send_header("content-type", "application/json;charset=utf-8") + self.send_header("connection", "close") + self.end_headers() + + def _handle(self): + parsed_path: ParseResult = urlparse(self.path) + path = parsed_path.path + self.received_requests[path] = self.received_requests.get(path, 0) + 1 + try: + body = {"ok": True} + self.send_response(HTTPStatus.OK) + self.set_common_headers() + self.wfile.write(json.dumps(body).encode("utf-8")) + self.wfile.close() + except Exception as e: + self.logger.error(str(e), exc_info=True) + raise + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + +# +# threading +# + + +class MockServerThread(threading.Thread): + def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + threading.Thread.__init__(self) + self.handler = handler + self.test = test + + def run(self): + self.server = HTTPServer(("localhost", 8888), self.handler) + self.test.mock_received_requests = self.handler.received_requests + self.test.server_url = "http://localhost:8888" + self.test.host, self.test.port = self.server.socket.getsockname() + self.test.server_started.set() # threading.Event() + + self.test = None + try: + self.server.serve_forever(0.05) + finally: + self.server.server_close() + + def stop(self): + self.handler.received_requests = {} + self.server.shutdown() + self.join() + + +def setup_mock_web_api_server(test: TestCase): + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() + + +def cleanup_mock_web_api_server(test: TestCase): + test.thread.stop() + test.thread = None diff --git a/tests/scenario_test/__init__.py b/tests/scenario_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scenario_test/test_app/__init__.py b/tests/scenario_test/test_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scenario_test/test_app/app.py b/tests/scenario_test/test_app/app.py new file mode 100644 index 0000000..342a370 --- /dev/null +++ b/tests/scenario_test/test_app/app.py @@ -0,0 +1,27 @@ +import os + +from slack_sdk import WebClient +from slack_bolt.app import App +from utils import get_test_socket_mode_handler, wait_for_test_socket_connection + +assert "SLACK_BOT_TOKEN" in os.environ +assert "SLACK_APP_TOKEN" in os.environ + +web_client = WebClient(base_url="http://localhost:8888", token=os.environ.get("SLACK_BOT_TOKEN")) + +app = App(signing_secret="valid", client=web_client) + +result = {"shortcut": False} + + +@app.shortcut("do-something") +def shortcut_handler(ack): + result["shortcut"] = True + ack() + + +if __name__ == "__main__": + print("ran as __main__") + handler = get_test_socket_mode_handler(3012, app, os.environ.get("SLACK_APP_TOKEN")) + handler.connect() + wait_for_test_socket_connection(handler, 2) diff --git a/tests/scenario_test/test_app/manifest.json b/tests/scenario_test/test_app/manifest.json new file mode 100644 index 0000000..17331e7 --- /dev/null +++ b/tests/scenario_test/test_app/manifest.json @@ -0,0 +1,6 @@ +{ + "_metadata": {}, + "display_information": { + "name": "Bolt app" + } +} \ No newline at end of file diff --git a/tests/scenario_test/test_app/my_app.py b/tests/scenario_test/test_app/my_app.py new file mode 100644 index 0000000..0639a73 --- /dev/null +++ b/tests/scenario_test/test_app/my_app.py @@ -0,0 +1,18 @@ +import os + +from slack_sdk import WebClient +from slack_bolt.app import App +from utils import get_test_socket_mode_handler, wait_for_test_socket_connection + +assert "SLACK_BOT_TOKEN" in os.environ +assert "SLACK_APP_TOKEN" in os.environ + +web_client = WebClient(base_url="http://localhost:8888", token=os.environ.get("SLACK_BOT_TOKEN")) + +app = App(signing_secret="valid", client=web_client) + +if __name__ == "__main__": + print("ran as __main__") + handler = get_test_socket_mode_handler(3012, app, os.environ.get("SLACK_APP_TOKEN")) + handler.connect() + wait_for_test_socket_connection(handler, 2) diff --git a/tests/scenario_test/test_app/utils.py b/tests/scenario_test/test_app/utils.py new file mode 100644 index 0000000..201036c --- /dev/null +++ b/tests/scenario_test/test_app/utils.py @@ -0,0 +1,23 @@ +import time + +from typing import Optional +from slack_bolt.adapter.socket_mode.builtin import SocketModeHandler +from slack_bolt.app.app import App + + +def get_test_socket_mode_handler(port: int, app: App, app_token: Optional[str]) -> SocketModeHandler: + handler = SocketModeHandler( + app, + app_token, + trace_enabled=True, + ) + handler.client.wss_uri = f"ws://localhost:{str(port)}/link" + handler.client.default_auto_reconnect_enabled = False + return handler + + +def wait_for_test_socket_connection(handler: SocketModeHandler, secs: int): + start_time = time.time() + while (time.time() - start_time) < secs: + if handler.client.is_connected() is True: + break diff --git a/tests/scenario_test/test_app_no_manifest/no_app_here.py b/tests/scenario_test/test_app_no_manifest/no_app_here.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scenario_test/test_get_hooks.py b/tests/scenario_test/test_get_hooks.py new file mode 100644 index 0000000..26d358e --- /dev/null +++ b/tests/scenario_test/test_get_hooks.py @@ -0,0 +1,24 @@ +import json +import runpy + +from slack_cli_hooks.hooks import get_hooks, get_manifest, start + + +class TestGetHooks: + def test_get_manifest(self, capsys): + runpy.run_module(get_hooks.__name__, run_name="__main__") + + out, err = capsys.readouterr() + json_response = json.loads(out) + assert err == "" + assert "hooks" in json_response + assert get_manifest.__name__ in json_response["hooks"]["get-manifest"] + + def test_start(self, capsys): + runpy.run_module(get_hooks.__name__, run_name="__main__") + + out, err = capsys.readouterr() + json_response = json.loads(out) + assert err == "" + assert "hooks" in json_response + assert start.__name__ in json_response["hooks"]["start"] diff --git a/tests/scenario_test/test_get_manifest.py b/tests/scenario_test/test_get_manifest.py new file mode 100644 index 0000000..bc26522 --- /dev/null +++ b/tests/scenario_test/test_get_manifest.py @@ -0,0 +1,33 @@ +import json +import os +import runpy +import pytest +from slack_cli_hooks.error import CliError +from slack_cli_hooks.hooks import get_manifest + + +class TestGetManifest: + def setup_method(self): + self.cwd = os.getcwd() + + def teardown_method(self): + os.chdir(self.cwd) + + def test_get_manifest_script(self, capsys): + working_directory = "tests/scenario_test/test_app" + os.chdir(working_directory) + + runpy.run_module(get_manifest.__name__, run_name="__main__") + + out, err = capsys.readouterr() + assert err == "" + assert {"_metadata": {}, "display_information": {"name": "Bolt app"}} == json.loads(out) + + def test_get_manifest_script_no_manifest(self): + working_directory = "tests/scenario_test/test_app_no_manifest" + os.chdir(working_directory) + + with pytest.raises(CliError) as e: + runpy.run_module(get_manifest.__name__, run_name="__main__") + + assert str(e.value) == "Could not find a manifest.json file" diff --git a/tests/scenario_test/test_start.py b/tests/scenario_test/test_start.py new file mode 100644 index 0000000..e88f047 --- /dev/null +++ b/tests/scenario_test/test_start.py @@ -0,0 +1,60 @@ +import runpy +import pytest +import os +from slack_cli_hooks.error import CliError + +from slack_cli_hooks.hooks import start +from tests.mock_socket_mode_server import start_socket_mode_server, stop_socket_mode_server +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestStart: + working_directory = "tests/scenario_test/test_app" + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + os.environ["SLACK_CLI_XOXB"] = "xoxb-valid" + os.environ["SLACK_CLI_XAPP"] = "xapp-A111-222-xyz" + setup_mock_web_api_server(self) + start_socket_mode_server(self, 3012) + self.cwd = os.getcwd() + + def teardown_method(self): + os.chdir(self.cwd) + os.environ.pop("SLACK_CLI_XOXB", None) + os.environ.pop("SLACK_CLI_XAPP", None) + os.environ.pop("SLACK_APP_PATH", None) + cleanup_mock_web_api_server(self) + stop_socket_mode_server(self) + restore_os_env(self.old_os_env) + + def test_start_script(self, capsys, caplog): + os.chdir(self.working_directory) + + runpy.run_module(start.__name__, run_name="__main__") + + captured_sys = capsys.readouterr() + + assert "ran as __main__" in captured_sys.out + assert "INFO A new session has been established" in caplog.text + + def test_start_with_entrypoint(self, capsys, caplog): + os.environ["SLACK_APP_PATH"] = "my_app.py" + os.chdir(self.working_directory) + + runpy.run_module(start.__name__, run_name="__main__") + + captured_sys = capsys.readouterr() + + assert "ran as __main__" in captured_sys.out + assert "INFO A new session has been established" in caplog.text + + def test_start_no_entrypoint(self): + working_directory = "tests/scenario_test/" + os.chdir(working_directory) + + with pytest.raises(CliError) as e: + runpy.run_module(start.__name__, run_name="__main__") + + assert "Could not find app.py file" in str(e.value) diff --git a/tests/slack_cli_hooks/__init__.py b/tests/slack_cli_hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/slack_cli_hooks/hooks/__init__.py b/tests/slack_cli_hooks/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/slack_cli_hooks/hooks/test_get_hooks.py b/tests/slack_cli_hooks/hooks/test_get_hooks.py new file mode 100644 index 0000000..2486865 --- /dev/null +++ b/tests/slack_cli_hooks/hooks/test_get_hooks.py @@ -0,0 +1,24 @@ +import re +from slack_cli_hooks.hooks.get_hooks import hooks_payload + + +class TestGetHooks: + def test_hooks_payload(self): + hooks = hooks_payload["hooks"] + + assert "slack_cli_hooks.hooks.get_manifest" in hooks["get-manifest"] + assert "slack_cli_hooks.hooks.start" in hooks["start"] + + def test_hooks_payload_config(self): + config = hooks_payload["config"] + + assert config["sdk-managed-connection-enabled"] is True + assert config["protocol-version"] == ["message-boundaries", "default"] + + def test_hooks_watch_regex(self): + config = hooks_payload["config"] + + assert config["watch"] is not None + + filter_regex = config["watch"]["filter-regex"] + assert re.match(filter_regex, "manifest.json") is not None diff --git a/tests/slack_cli_hooks/hooks/test_get_manifest.py b/tests/slack_cli_hooks/hooks/test_get_manifest.py new file mode 100644 index 0000000..783a29e --- /dev/null +++ b/tests/slack_cli_hooks/hooks/test_get_manifest.py @@ -0,0 +1,47 @@ +from unittest import mock + +import pytest +from slack_cli_hooks.error import CliError +from slack_cli_hooks.hooks.get_manifest import filter_directories, find_file_path + + +class TestGetManifest: + def test_filter_directories(self): + test_directories = [ + "lib", + "bin", + "include", + "src", + "tmp", + "test", + "tests", + ] + + actual = filter_directories(test_directories) + + assert actual == ["src"] + + def test_find_file_path(self): + with mock.patch("os.walk") as mock_walk: + mock_walk.return_value = [ + ["/dir", ["subdir"], ["utils.py", "my_app.py", "manifest.json", "app.py"]], + ["/dir/subdir", [], ["spam.json", "hello.txt"]], + ] + + manifest_path = find_file_path("/dir", "manifest.json") + spam_path = find_file_path("/dir", "spam.json") + + assert manifest_path == "/dir/manifest.json" + assert spam_path == "/dir/subdir/spam.json" + + def test_find_file_path_error(self): + test_file_name = "test.json" + + with mock.patch("os.walk") as mock_walk: + mock_walk.return_value = [ + ["/dir", [], ["utils.py"]], + ] + with pytest.raises(CliError) as e: + find_file_path("/dir", test_file_name) + + assert str(e.value) == f"Could not find a {test_file_name} file" diff --git a/tests/slack_cli_hooks/hooks/test_start.py b/tests/slack_cli_hooks/hooks/test_start.py new file mode 100644 index 0000000..c475117 --- /dev/null +++ b/tests/slack_cli_hooks/hooks/test_start.py @@ -0,0 +1,55 @@ +import pytest +import os +from slack_cli_hooks.error import CliError + +from slack_cli_hooks.hooks.start import validate_env, get_main_file, get_main_path +from tests.utils import remove_os_env_temporarily, restore_os_env + +SLACK_CLI_XOXB = "SLACK_CLI_XOXB" +SLACK_CLI_XAPP = "SLACK_CLI_XAPP" +SLACK_APP_PATH = "SLACK_APP_PATH" + + +class TestStart: + working_directory = "tests/slack_bolt/cli/test_app" + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + + def teardown_method(self): + os.environ.pop(SLACK_CLI_XOXB, None) + os.environ.pop(SLACK_CLI_XAPP, None) + os.environ.pop(SLACK_APP_PATH, None) + restore_os_env(self.old_os_env) + + def test_validate_env(self): + os.environ[SLACK_CLI_XOXB] = "xoxb-valid" + os.environ[SLACK_CLI_XAPP] = "xapp-A111-222-xyz" + + assert validate_env() is None + + def test_validate_env_with_missing_xoxb(self): + os.environ[SLACK_CLI_XAPP] = "xapp-A111-222-xyz" + with pytest.raises(CliError) as e: + validate_env() + assert str(e.value) == f"Missing local run bot token ({SLACK_CLI_XOXB})." + + def test_validate_env_with_missing_xapp(self): + os.environ[SLACK_CLI_XOXB] = "xoxb-valid" + with pytest.raises(CliError) as e: + validate_env() + assert str(e.value) == f"Missing local run app token ({SLACK_CLI_XAPP})." + + def test_get_main_file(self): + assert get_main_file() == "app.py" + + def test_get_main_file_with_override(self): + os.environ[SLACK_APP_PATH] = "my_app.py" + assert get_main_file() == "my_app.py" + + def test_get_main_path(self): + assert get_main_path("/dir") == "/dir/app.py" + + def test_get_main_path_from_var(self): + os.environ[SLACK_APP_PATH] = "my_app.py" + assert get_main_path("/dir") == "/dir/my_app.py" diff --git a/tests/slack_cli_hooks/hooks/utils/__init__.py b/tests/slack_cli_hooks/hooks/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/slack_cli_hooks/hooks/utils/test_managed_os_env_vars.py b/tests/slack_cli_hooks/hooks/utils/test_managed_os_env_vars.py new file mode 100644 index 0000000..38370f9 --- /dev/null +++ b/tests/slack_cli_hooks/hooks/utils/test_managed_os_env_vars.py @@ -0,0 +1,76 @@ +import os +from slack_cli_hooks.hooks.utils import ManagedOSEnvVars +from slack_cli_hooks.protocol import DefaultProtocol +from tests.utils import remove_os_env_temporarily, restore_os_env + +TEST_VAR = "TEST" +TEST_VAR2 = "TEST2" + + +class TestManagedOSEnvVars: + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + + def teardown_method(self): + os.environ.pop(TEST_VAR, None) + os.environ.pop(TEST_VAR2, None) + restore_os_env(self.old_os_env) + + def test_set_if_absent(self): + expected = "expected test value" + os_env_vars = ManagedOSEnvVars(DefaultProtocol()) + os_env_vars.set_if_absent(TEST_VAR, expected) + + assert TEST_VAR in os.environ + assert os.environ[TEST_VAR] == expected + + def test_set_default_does_not_overwrite(self): + expected = "default test value" + os.environ[TEST_VAR] = expected + + os_env_vars = ManagedOSEnvVars(DefaultProtocol()) + os_env_vars.set_if_absent(TEST_VAR, "nothing") + + assert TEST_VAR in os.environ + assert os.environ[TEST_VAR] == expected + + def test_clear(self): + expected = "expected test value" + os_env_vars = ManagedOSEnvVars(DefaultProtocol()) + os_env_vars.set_if_absent(TEST_VAR, expected) + + os_env_vars.clear() + + assert not (TEST_VAR in os.environ) + + def test_clear_does_not_overwrite(self): + expected = "expected test value" + os.environ[TEST_VAR] = expected + + os_env_vars = ManagedOSEnvVars(DefaultProtocol()) + os_env_vars.set_if_absent(TEST_VAR, "nothing") + + os_env_vars.clear() + + assert TEST_VAR in os.environ + assert os.environ[TEST_VAR] == expected + + def test_clear_only_clears_absent_vars(self): + expected = "expected test value" + os.environ[TEST_VAR] = expected + + os_env_vars = ManagedOSEnvVars(DefaultProtocol()) + os_env_vars.set_if_absent(TEST_VAR, "nothing") + os_env_vars.set_if_absent(TEST_VAR2, expected) + + os_env_vars.clear() + + assert TEST_VAR in os.environ + assert os.environ[TEST_VAR] == expected + assert not (TEST_VAR2 in os.environ) + + def test_no_env_var_set(self): + os_env_vars = ManagedOSEnvVars(DefaultProtocol()) + + os_env_vars.clear() + assert TEST_VAR not in os.environ diff --git a/tests/slack_cli_hooks/protocol/__init__.py b/tests/slack_cli_hooks/protocol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/slack_cli_hooks/protocol/test_default_protocol.py b/tests/slack_cli_hooks/protocol/test_default_protocol.py new file mode 100644 index 0000000..2a45b0c --- /dev/null +++ b/tests/slack_cli_hooks/protocol/test_default_protocol.py @@ -0,0 +1,41 @@ +from slack_cli_hooks.protocol import DefaultProtocol + + +class TestDefaultProtocol: + def test_name(self): + assert DefaultProtocol.name == "default" + + def test_debug(self, capsys): + DefaultProtocol().debug("test") + + out, err = capsys.readouterr() + assert out == "" + assert err == "" + + def test_info(self, capsys): + DefaultProtocol().info("test") + + out, err = capsys.readouterr() + assert out == "" + assert err == "" + + def test_warning(self, capsys): + DefaultProtocol().warning("test") + + out, err = capsys.readouterr() + assert out == "" + assert err == "" + + def test_error(self, capsys): + DefaultProtocol().error("test") + + out, err = capsys.readouterr() + assert out == "" + assert err == "" + + def test_respond(self, capsys): + DefaultProtocol().respond("test") + + out, err = capsys.readouterr() + assert out == "test\n" + assert err == "" diff --git a/tests/slack_cli_hooks/protocol/test_mssage_boundary_protocol.py b/tests/slack_cli_hooks/protocol/test_mssage_boundary_protocol.py new file mode 100644 index 0000000..fcd8504 --- /dev/null +++ b/tests/slack_cli_hooks/protocol/test_mssage_boundary_protocol.py @@ -0,0 +1,56 @@ +from slack_cli_hooks.protocol import MessageBoundaryProtocol + + +class TestMessageBoundaryProtocol: + def test_name(self): + assert MessageBoundaryProtocol.name == "message-boundaries" + + def test_debug(self, capsys, caplog): + protocol = MessageBoundaryProtocol("bound") + + protocol.debug("test") + + out, err = capsys.readouterr() + assert out == "" + assert err == "" + assert "DEBUG test" in caplog.text + + def test_info(self, capsys, caplog): + protocol = MessageBoundaryProtocol("bound") + + protocol.info("test") + + out, err = capsys.readouterr() + assert out == "" + assert err == "" + assert "INFO test" in caplog.text + + def test_warning(self, capsys, caplog): + protocol = MessageBoundaryProtocol("bound") + + protocol.warning("test") + + out, err = capsys.readouterr() + assert out == "" + assert err == "" + assert "WARNING test" in caplog.text + + def test_error(self, capsys, caplog): + protocol = MessageBoundaryProtocol("bound") + + protocol.error("test") + + out, err = capsys.readouterr() + assert out == "" + assert err == "" + assert "ERROR test" in caplog.text + + def test_respond(self, capsys, caplog): + protocol = MessageBoundaryProtocol("bound") + + protocol.respond("test") + + out, err = capsys.readouterr() + assert out == "boundtestbound\n" + assert err == "" + assert "" in caplog.text diff --git a/tests/slack_cli_hooks/protocol/test_protocol_factory.py b/tests/slack_cli_hooks/protocol/test_protocol_factory.py new file mode 100644 index 0000000..c926a24 --- /dev/null +++ b/tests/slack_cli_hooks/protocol/test_protocol_factory.py @@ -0,0 +1,15 @@ +from slack_cli_hooks.protocol import build_protocol, DefaultProtocol, MessageBoundaryProtocol, Protocol + + +class TestProtocolFactory: + def test_default(self): + args = [] + protocol = build_protocol(args) + assert isinstance(protocol, Protocol) + assert isinstance(protocol, DefaultProtocol) + + def test_message_boundaries(self): + args = [f"--protocol={MessageBoundaryProtocol.name}", "--bound=boundary"] + protocol = build_protocol(args) + assert isinstance(protocol, Protocol) + assert isinstance(protocol, MessageBoundaryProtocol) diff --git a/tests/test_dummy.py b/tests/test_dummy.py deleted file mode 100644 index 22ed597..0000000 --- a/tests/test_dummy.py +++ /dev/null @@ -1,9 +0,0 @@ -class TestDummy: - def setup_method(self): - pass - - def teardown_method(self): - pass - - def test_dummy(self): - assert True diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..185e41b --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,11 @@ +import os + + +def remove_os_env_temporarily() -> dict: + old_env = os.environ.copy() + os.environ.clear() + return old_env + + +def restore_os_env(old_env: dict) -> None: + os.environ.update(old_env)