From f6c355cae11bc63556ff386f1d10168485c746fd Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 Nov 2023 13:39:41 -0500 Subject: [PATCH 1/7] base project --- .gitignore | 71 +++++++++----------------------------- LICENSE | 2 +- pyproject.toml | 62 +++++++++++++++++++++++++++++++++ requirements.txt | 1 + slack_cli_hooks/version.py | 2 ++ 5 files changed, 83 insertions(+), 55 deletions(-) create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 slack_cli_hooks/version.py diff --git a/.gitignore b/.gitignore index 68bc17f..eabc2dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ +# general things to ignore +tmp.txt +.DS_Store +logs/ +*.db +*.so +*~ +docs/_sources/ +docs/.doctrees + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -65,9 +75,6 @@ db.sqlite3-journal instance/ .webassets-cache -# Scrapy stuff: -.scrapy - # Sphinx documentation docs/_build/ @@ -85,67 +92,23 @@ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml +.python-version # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - # Environments .env .venv -env/ -venv/ +env*/ +venv*/ +.venv*/ +.env*/ +.env* ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - # pytype static type analyzer .pytype/ @@ -157,4 +120,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ diff --git a/LICENSE b/LICENSE index 67f5552..85a0798 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 SlackAPI +Copyright (c) 2023- Slack Technologies, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..66657c1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["setuptools", "pytest-runner", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "slack_cli_hooks" +dynamic = ["version", "readme", "dependencies"] +description = "The Slack CLI contract implementation for Bolt Python" +license = { text = "MIT" } +authors = [{ name = "Slack Technologies, LLC", email = "opensource@slack.com" }] +requires-python = ">=3.6" +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "Topic :: Office/Business", + "Topic :: Software Development", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] + + +[project.urls] +"Source Code" = "https://github.com/slackapi/python-slack-hooks" + + +[tool.setuptools.packages.find] +include = ["slack_cli_hooks*"] + +[tool.setuptools.dynamic] +version = { attr = "slack_cli_hooks.version.__version__" } +readme = { file = ["README.md"], content-type = "text/markdown" } +dependencies = { file = ["requirements.txt"] } + +[tool.distutils.bdist_wheel] +universal = true + +[tool.black] +line-length = 125 + +[tool.pytest.ini_options] +testpaths = ["tests"] +log_file = "logs/pytest.log" +log_file_level = "DEBUG" +log_format = "%(asctime)s %(levelname)s %(message)s" +log_date_format = "%Y-%m-%d %H:%M:%S" +filterwarnings = [ + "ignore:\"@coroutine\" decorator is deprecated since Python 3.8, use \"async def\" instead:DeprecationWarning", + "ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning", + "ignore:slack.* package is deprecated. Please use slack_sdk.* package instead.*:UserWarning", +] +asyncio_mode = "auto" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e3ccbaa --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +slack_bolt>=1.18.0,<2 diff --git a/slack_cli_hooks/version.py b/slack_cli_hooks/version.py new file mode 100644 index 0000000..311412d --- /dev/null +++ b/slack_cli_hooks/version.py @@ -0,0 +1,2 @@ +"""Check the latest version at https://pypi.org/project/slack-cli-hooks/""" +__version__ = "0.0.0" From e69a40c35ca5477626e1c516bf2a184100a92984 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 Nov 2023 13:39:58 -0500 Subject: [PATCH 2/7] Add code --- slack_cli_hooks/__init__.py | 31 ++++ 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 | 63 ++++++++ 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 ++++++ tests/mock_socket_mode_server.py | 141 ++++++++++++++++++ tests/scenario_test/__init__.py | 0 tests/scenario_test/test_app/__init__.py | 0 tests/scenario_test/test_app/app.py | 18 +++ 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 | 26 ++++ tests/scenario_test/test_get_manifest.py | 33 ++++ tests/scenario_test/test_start.py | 58 +++++++ tests/slack_cli_hooks/hooks/__init__.py | 0 tests/slack_cli_hooks/hooks/test_get_hooks.py | 15 ++ .../hooks/test_get_manifest.py | 47 ++++++ tests/slack_cli_hooks/hooks/test_start.py | 56 +++++++ 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/utils.py | 40 +++++ 30 files changed, 880 insertions(+) create mode 100644 slack_cli_hooks/__init__.py 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/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 tests/mock_socket_mode_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/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/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 create mode 100644 tests/utils.py diff --git a/slack_cli_hooks/__init__.py b/slack_cli_hooks/__init__.py new file mode 100644 index 0000000..b1065ca --- /dev/null +++ b/slack_cli_hooks/__init__.py @@ -0,0 +1,31 @@ +""" +A Python framework to build Slack apps in a flash with the latest platform features.Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. + +* Website: https://slack.dev/bolt-python/ +* GitHub repository: https://github.com/slackapi/bolt-python +* The class representing a Bolt app: `slack_bolt.app.app` +""" # noqa: E501 +# Don't add async module imports here +from .app import App +from .context import BoltContext +from .context.ack import Ack +from .context.respond import Respond +from .context.say import Say +from .kwargs_injection import Args +from .listener import Listener +from .listener_matcher import CustomListenerMatcher +from .request import BoltRequest +from .response import BoltResponse + +__all__ = [ + "App", + "BoltContext", + "Ack", + "Respond", + "Say", + "Args", + "Listener", + "CustomListenerMatcher", + "BoltRequest", + "BoltResponse", +] 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..872f135 --- /dev/null +++ b/slack_cli_hooks/hooks/__init__.py @@ -0,0 +1,2 @@ +"""Cli modules to allow Bolt apps to interact with the bolt cli. +""" diff --git a/slack_cli_hooks/hooks/get_hooks.py b/slack_cli_hooks/hooks/get_hooks.py new file mode 100644 index 0000000..3b8004b --- /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, protocol_factory +from slack_cli_hooks.hooks import get_manifest, start + +PROTOCOL: Protocol +EXEC = "python3" + +hooks_payload = { + "hooks": { + "get-manifest": f"{EXEC} -m {get_manifest.__name__}", + "start": f"{EXEC} -m {start.__name__}", + }, + "config": { + "watcher": {"filter-regex": "^manifest\\.(json)$", "paths": ["."]}, + "protocol-version": [MessageBoundaryProtocol.name, DefaultProtocol.name], + "sdk-managed-connection-enabled": True, + }, +} + +if __name__ == "__main__": + PROTOCOL = protocol_factory() + 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..80297a0 --- /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, protocol_factory + +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 = protocol_factory() + 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..98778c7 --- /dev/null +++ b/slack_cli_hooks/hooks/start.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +import os +import runpy +import sys + +from slack_cli_hooks.error import CliError +from slack_cli_hooks.protocol import Protocol, protocol_factory + +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) + + try: + os.environ[SLACK_BOT_TOKEN] = os.environ[SLACK_CLI_XOXB] + os.environ[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.environ.pop(SLACK_BOT_TOKEN, None) + os.environ.pop(SLACK_APP_TOKEN, None) + + +if __name__ == "__main__": + PROTOCOL = protocol_factory() + start(os.getcwd()) diff --git a/slack_cli_hooks/protocol/__init__.py b/slack_cli_hooks/protocol/__init__.py new file mode 100644 index 0000000..665d1f9 --- /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 protocol_factory(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/tests/mock_socket_mode_server.py b/tests/mock_socket_mode_server.py new file mode 100644 index 0000000..68ee0fc --- /dev/null +++ b/tests/mock_socket_mode_server.py @@ -0,0 +1,141 @@ +import json +import logging +import sys +import threading +import time +from urllib.request import urlopen +from urllib.error import URLError +from multiprocessing.context import Process +from typing import List +from unittest import TestCase + +from tests.utils import get_mock_server_mode + +socket_mode_envelopes = [ + """{"envelope_id":"57d6a792-4d35-4d0b-b6aa-3361493e1caf","payload":{"type":"shortcut","token":"xxx","action_ts":"1610198080.300836","team":{"id":"T111","domain":"seratch"},"user":{"id":"U111","username":"seratch","team_id":"T111"},"is_enterprise_install":false,"enterprise":null,"callback_id":"do-something","trigger_id":"111.222.xxx"},"type":"interactive","accepts_response_payload":false}""", + """{"envelope_id":"1d3c79ab-0ffb-41f3-a080-d19e85f53649","payload":{"token":"xxx","team_id":"T111","team_domain":"xxx","channel_id":"C111","channel_name":"random","user_id":"U111","user_name":"seratch","command":"/hello-socket-mode","text":"","api_app_id":"A111","response_url":"https://hooks.slack.com/commands/T111/111/xxx","trigger_id":"111.222.xxx"},"type":"slash_commands","accepts_response_payload":true}""", + """{"envelope_id":"08cfc559-d933-402e-a5c1-79e135afaae4","payload":{"token":"xxx","team_id":"T111","api_app_id":"A111","event":{"client_msg_id":"c9b466b5-845c-49c6-a371-57ae44359bf1","type":"message","text":"<@W111>","user":"U111","ts":"1610197986.000300","team":"T111","blocks":[{"type":"rich_text","block_id":"1HBPc","elements":[{"type":"rich_text_section","elements":[{"type":"user","user_id":"U111"}]}]}],"channel":"C111","event_ts":"1610197986.000300","channel_type":"channel"},"type":"event_callback","event_id":"Ev111","event_time":1610197986,"authorizations":[{"enterprise_id":null,"team_id":"T111","user_id":"U111","is_bot":true,"is_enterprise_install":false}],"is_ext_shared_channel":false,"event_context":"1-message-T111-C111"},"type":"events_api","accepts_response_payload":false,"retry_attempt":1,"retry_reason":"timeout"}""", +] + +from flask import Flask +from flask_sockets import Sockets + + +def start_thread_socket_mode_server(test: TestCase, port: int): + def _start_thread_socket_mode_server(): + logger = logging.getLogger(__name__) + app: Flask = Flask(__name__) + + @app.route("/state") + def state(): + return json.dumps({"success": True}), 200, {"ContentType": "application/json"} + + sockets: Sockets = Sockets(app) + + envelopes_to_consume: List[str] = list(socket_mode_envelopes) + + @sockets.route("/link") + def link(ws): + while not ws.closed: + message = ws.read_message() + if message is not None: + if len(envelopes_to_consume) > 0: + e = envelopes_to_consume.pop(0) + logger.debug(f"Send an envelope: {e}") + ws.send(e) + + logger.debug(f"Server received a message: {message}") + ws.send(message) + + from gevent import pywsgi + from geventwebsocket.handler import WebSocketHandler + + 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_process_socket_mode_server(port: int): + logger = logging.getLogger(__name__) + app: Flask = Flask(__name__) + sockets: Sockets = Sockets(app) + + envelopes_to_consume: List[str] = list(socket_mode_envelopes) + + @sockets.route("/link") + def link(ws): + while not ws.closed: + message = ws.read_message() + if message is not None: + if len(envelopes_to_consume) > 0: + e = envelopes_to_consume.pop(0) + logger.debug(f"Send an envelope: {e}") + ws.send(e) + + logger.debug(f"Server received a message: {message}") + ws.send(message) + + from gevent import pywsgi + from geventwebsocket.handler import WebSocketHandler + + server = pywsgi.WSGIServer(("", port), app, handler_class=WebSocketHandler) + server.serve_forever(stop_timeout=1) + + +def start_socket_mode_server(test, port: int): + if get_mock_server_mode() == "threading": + 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 + else: + test.sm_process = Process(target=start_process_socket_mode_server, kwargs={"port": port}) + test.sm_process.start() + + +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): + if get_mock_server_mode() == "threading": + print(test) + test.server.stop() + test.server.close() + else: + # terminate the process + test.sm_process.terminate() + test.sm_process.join() + # Python 3.6 does not have these methods + if sys.version_info.major == 3 and sys.version_info.minor > 6: + # cleanup the process's resources + test.sm_process.kill() + test.sm_process.close() + + test.sm_process = None + + +async def stop_socket_mode_server_async(test: TestCase): + if get_mock_server_mode() == "threading": + test.server.stop() + test.server.close() + else: + # terminate the process + test.sm_process.terminate() + test.sm_process.join() + + # Python 3.6 does not have these methods + if sys.version_info.major == 3 and sys.version_info.minor > 6: + # cleanup the process's resources + test.sm_process.kill() + test.sm_process.close() + + test.sm_process = 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..a5ca68e --- /dev/null +++ b/tests/scenario_test/test_app/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(f"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..a5ca68e --- /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(f"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..9a74591 --- /dev/null +++ b/tests/scenario_test/test_get_hooks.py @@ -0,0 +1,26 @@ +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 is "" + 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 is "" + 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..1d78aea --- /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_tests/cli/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_tests/cli/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..efc73c8 --- /dev/null +++ b/tests/scenario_test/test_start.py @@ -0,0 +1,58 @@ +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.utils import remove_os_env_temporarily, restore_os_env + + +class TestStart: + + working_directory = "tests/scenario_tests/cli/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" + 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) + 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_tests/cli" + 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/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..564b759 --- /dev/null +++ b/tests/slack_cli_hooks/hooks/test_get_hooks.py @@ -0,0 +1,15 @@ +from slack_cli_hooks.hooks.get_hooks import hooks_payload + + +class TestGetHooks: + def test_hooks_payload(self): + hooks = hooks_payload["hooks"] + + assert "slack_bolt.cli.get_manifest" in hooks["get-manifest"] + assert "slack_bolt.cli.start" in hooks["start"] + + def test_hooks_payload_config(self): + config = hooks_payload["config"] + + assert config["sdk-managed-connection-enabled"] == True + assert config["protocol-version"] == ["message-boundaries", "default"] 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..5427400 --- /dev/null +++ b/tests/slack_cli_hooks/hooks/test_start.py @@ -0,0 +1,56 @@ +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() == 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(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/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..c465f54 --- /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_error(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..6a1261a --- /dev/null +++ b/tests/slack_cli_hooks/protocol/test_protocol_factory.py @@ -0,0 +1,15 @@ +from slack_cli_hooks.protocol import protocol_factory, DefaultProtocol, MessageBoundaryProtocol, Protocol + + +class TestProtocolFactory: + def test_default(self): + args = [] + protocol = protocol_factory(args) + assert isinstance(protocol, Protocol) + assert isinstance(protocol, DefaultProtocol) + + def test_message_boundaries(self): + args = [f"--protocol={MessageBoundaryProtocol.name}", "--bound=boundary"] + protocol = protocol_factory(args) + assert isinstance(protocol, Protocol) + assert isinstance(protocol, MessageBoundaryProtocol) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..733a7c4 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,40 @@ +import os +import asyncio + + +def remove_os_env_temporarily() -> dict: + old_env = os.environ.copy() + os.environ.clear() + for key, value in old_env.items(): + if key.startswith("BOLT_PYTHON_"): + os.environ[key] = value + return old_env + + +def restore_os_env(old_env: dict) -> None: + os.environ.update(old_env) + + +def get_mock_server_mode() -> str: + """Returns a str representing the mode. + + :return: threading/multiprocessing + """ + mode = os.environ.get("BOLT_PYTHON_MOCK_SERVER_MODE") + if mode is None: + # We used to use "multiprocessing"" for macOS until Big Sur 11.1 + # Since 11.1, the "multiprocessing" mode started failing a lot... + # Therefore, we switched the default mode back to "threading". + return "threading" + else: + return mode + + +def get_event_loop(): + try: + return asyncio.get_event_loop() + except RuntimeError as ex: + if "There is no current event loop in thread" in str(ex): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop From 70821028284cb9daa34a1c93ba3be121fde2fecc Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 Nov 2023 13:40:41 -0500 Subject: [PATCH 3/7] add test requirements --- requirements/build.txt | 3 +++ requirements/format.txt | 4 ++++ requirements/testing.txt | 8 ++++++++ 3 files changed, 15 insertions(+) create mode 100644 requirements/build.txt create mode 100644 requirements/format.txt create mode 100644 requirements/testing.txt diff --git a/requirements/build.txt b/requirements/build.txt new file mode 100644 index 0000000..4377557 --- /dev/null +++ b/requirements/build.txt @@ -0,0 +1,3 @@ +# pip install -r requirements/build.txt +twine +build diff --git a/requirements/format.txt b/requirements/format.txt new file mode 100644 index 0000000..45d741e --- /dev/null +++ b/requirements/format.txt @@ -0,0 +1,4 @@ +black==22.8.0 # 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" +pytype==2022.12.15; python_version>"3.6" diff --git a/requirements/testing.txt b/requirements/testing.txt new file mode 100644 index 0000000..146ee49 --- /dev/null +++ b/requirements/testing.txt @@ -0,0 +1,8 @@ +# pip install -r requirements/testing.txt +pytest>=6.2.5,<7 +pytest-cov>=3,<4 +Flask-Sockets>=0.2,<1 # TODO: This module is not yet Flask 2.x compatible +Werkzeug>=1,<2 # TODO: Flask-Sockets is not yet compatible with Flask 2.x +itsdangerous==2.0.1 # TODO: Flask-Sockets is not yet compatible with Flask 2.x +Jinja2==3.0.3 # https://github.com/pallets/flask/issues/4494 +click<=8.0.4 # black is affected by https://github.com/pallets/click/issues/2225 From ecdaf4d71892e218316eb4c158e102a9d5a01a5c Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 Nov 2023 13:41:12 -0500 Subject: [PATCH 4/7] Add maintainer scripts --- scripts/_setup.sh | 8 ++++++++ scripts/build_pypi_package.sh | 8 ++++++++ scripts/deploy_to_prod_pypi.sh | 5 +++++ scripts/deploy_to_test_pypi.sh | 5 +++++ scripts/format.sh | 6 ++++++ scripts/generate_api_docs.sh | 9 +++++++++ scripts/install.sh | 16 ++++++++++++++++ scripts/install_and_run_tests.sh | 15 +++++++++++++++ scripts/run_flake8.sh | 8 ++++++++ scripts/run_pytype.sh | 8 ++++++++ scripts/run_tests.sh | 17 +++++++++++++++++ scripts/uninstall_all.sh | 4 ++++ 12 files changed, 109 insertions(+) create mode 100644 scripts/_setup.sh create mode 100755 scripts/build_pypi_package.sh create mode 100755 scripts/deploy_to_prod_pypi.sh create mode 100644 scripts/deploy_to_test_pypi.sh create mode 100755 scripts/format.sh create mode 100755 scripts/generate_api_docs.sh create mode 100755 scripts/install.sh create mode 100755 scripts/install_and_run_tests.sh create mode 100755 scripts/run_flake8.sh create mode 100755 scripts/run_pytype.sh create mode 100755 scripts/run_tests.sh create mode 100755 scripts/uninstall_all.sh diff --git a/scripts/_setup.sh b/scripts/_setup.sh new file mode 100644 index 0000000..728a45a --- /dev/null +++ b/scripts/_setup.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Set root of project as current working directory +script_dir=`dirname $0` +cd ${script_dir}/.. + +rm -rf ./slack_cli_hooks.egg-info +pip install -U pip diff --git a/scripts/build_pypi_package.sh b/scripts/build_pypi_package.sh new file mode 100755 index 0000000..5d6bdaf --- /dev/null +++ b/scripts/build_pypi_package.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +sh scripts/_setup.sh + +pip install -r requirements/build.txt && \ + rm -rf dist/ build/ slack_cli_hooks.egg-info/ && \ + python -m build --sdist --wheel && \ + twine check dist/* diff --git a/scripts/deploy_to_prod_pypi.sh b/scripts/deploy_to_prod_pypi.sh new file mode 100755 index 0000000..b14401b --- /dev/null +++ b/scripts/deploy_to_prod_pypi.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +sh scripts/build_pypi_package.sh + +twine upload dist/* diff --git a/scripts/deploy_to_test_pypi.sh b/scripts/deploy_to_test_pypi.sh new file mode 100644 index 0000000..3b63dda --- /dev/null +++ b/scripts/deploy_to_test_pypi.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +sh scripts/build_pypi_package.sh + +twine upload --repository testpypi dist/* diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..0fdbd23 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +sh scripts/_setup.sh + +pip install -r requirements/format.txt +black slack_cli_hooks/ tests/ diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh new file mode 100755 index 0000000..397b4f7 --- /dev/null +++ b/scripts/generate_api_docs.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Generate API documents from the latest source code + +sh scripts/_setup.sh + +pip install -U pdoc3 +rm -rf docs/api-docs +pdoc slack_cli_hooks --html -o docs/api-docs +open docs/api-docs/slack_cli_hooks/index.html diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..8bef9f9 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +sh scripts/setup.sh + +test_target="$1" +python_version=`python --version | awk '{print $2}'` + +if [ ${python_version:0:3} == "3.6" ] +then + pip install -r requirements.txt +else + pip install -e . +fi + +pip install -r requirements/testing.txt +pip install -r requirements/format.txt diff --git a/scripts/install_and_run_tests.sh b/scripts/install_and_run_tests.sh new file mode 100755 index 0000000..32a49eb --- /dev/null +++ b/scripts/install_and_run_tests.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Run all the tests or a single test +# all: ./scripts/install_all_and_run_tests.sh +# single: ./scripts/install_all_and_run_tests.sh tests/scenario_tests/test_app.py + +sh scripts/install.sh +sh scripts/format.sh + +if [[ $test_target != "" ]] +then + pytest $1 +else + pytest && \ + pytype slack_cli_hooks/ +fi diff --git a/scripts/run_flake8.sh b/scripts/run_flake8.sh new file mode 100755 index 0000000..38e541a --- /dev/null +++ b/scripts/run_flake8.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ./scripts/run_flake8.sh + +sh script/setup.py + +pip install -r requirements/format.txt + +flake8 slack_cli_hooks/ && flake8 tests/ diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh new file mode 100755 index 0000000..2506eb3 --- /dev/null +++ b/scripts/run_pytype.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ./scripts/run_pytype.sh + +sh scripts/install.sh + +pip install -r requirements/format.txt + +pytype slack_cli_hooks/ diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 0000000..839dee9 --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Run all the tests or a single test +# all: ./scripts/run_tests.sh +# single: ./scripts/run_tests.sh tests/scenario_tests/test_app.py + +test_target="$1" +python_version=`python --version | awk '{print $2}'` + +sh scripts/setup.sh +black slack_cli_hooks/ tests/ + +if [[ $test_target != "" ]] +then + pytest -vv $1 +else + pytest +fi diff --git a/scripts/uninstall_all.sh b/scripts/uninstall_all.sh new file mode 100755 index 0000000..1d3da26 --- /dev/null +++ b/scripts/uninstall_all.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +pip uninstall -y slack-bolt && \ + pip freeze | grep -v "^-e" | xargs pip uninstall -y From a0e99a438732e9ef821e0c0165734786e88c38ed Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 Nov 2023 14:20:56 -0500 Subject: [PATCH 5/7] Make tests pass --- pyproject.toml | 1 - requirements/format.txt | 6 +- scripts/run_tests.sh | 2 +- slack_cli_hooks/__init__.py | 24 +- tests/__init__.py | 0 tests/mock_web_api_server.py | 380 ++++++++++++++++++ tests/scenario_test/test_get_hooks.py | 2 - tests/scenario_test/test_get_manifest.py | 4 +- tests/scenario_test/test_start.py | 8 +- tests/slack_cli_hooks/hooks/test_get_hooks.py | 4 +- tests/slack_cli_hooks/hooks/test_start.py | 1 - 11 files changed, 398 insertions(+), 34 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/mock_web_api_server.py diff --git a/pyproject.toml b/pyproject.toml index 66657c1..05a7d45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,4 +59,3 @@ filterwarnings = [ "ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning", "ignore:slack.* package is deprecated. Please use slack_sdk.* package instead.*:UserWarning", ] -asyncio_mode = "auto" diff --git a/requirements/format.txt b/requirements/format.txt index 45d741e..b54dc45 100644 --- a/requirements/format.txt +++ b/requirements/format.txt @@ -1,4 +1,6 @@ -black==22.8.0 # Until we drop Python 3.6 support, we have to stay with this version +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" -pytype==2022.12.15; python_version>"3.6" +pytype==2022.12.15; python_version=="3.9" +pytype; python_version<"3.9" and python_version>'3.9' diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 839dee9..996724e 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -13,5 +13,5 @@ if [[ $test_target != "" ]] then pytest -vv $1 else - pytest + pytest tests/* fi diff --git a/slack_cli_hooks/__init__.py b/slack_cli_hooks/__init__.py index b1065ca..395da3d 100644 --- a/slack_cli_hooks/__init__.py +++ b/slack_cli_hooks/__init__.py @@ -6,26 +6,10 @@ * The class representing a Bolt app: `slack_bolt.app.app` """ # noqa: E501 # Don't add async module imports here -from .app import App -from .context import BoltContext -from .context.ack import Ack -from .context.respond import Respond -from .context.say import Say -from .kwargs_injection import Args -from .listener import Listener -from .listener_matcher import CustomListenerMatcher -from .request import BoltRequest -from .response import BoltResponse +from .hooks import get_hooks, get_manifest, start __all__ = [ - "App", - "BoltContext", - "Ack", - "Respond", - "Say", - "Args", - "Listener", - "CustomListenerMatcher", - "BoltRequest", - "BoltResponse", + "get_hooks", + "get_manifest", + "start", ] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py new file mode 100644 index 0000000..91a67f1 --- /dev/null +++ b/tests/mock_web_api_server.py @@ -0,0 +1,380 @@ +import asyncio +import json +import logging +import sys +import threading +import time +from http import HTTPStatus +from http.server import HTTPServer, SimpleHTTPRequestHandler +from typing import Type, Optional +from unittest import TestCase +from urllib.parse import urlparse, parse_qs, ParseResult + +from multiprocessing import Process +from urllib.request import urlopen, Request + +from tests.utils import get_mock_server_mode + + +class MockHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + default_request_version = "HTTP/1.1" + logger = logging.getLogger(__name__) + received_requests = {} + + def is_valid_token(self): + return "Authorization" in self.headers and str(self.headers["Authorization"]).startswith("Bearer xoxb-") + + def is_valid_user_token(self): + return "Authorization" in self.headers and str(self.headers["Authorization"]).startswith("Bearer xoxp-") + + def set_common_headers(self): + self.send_header("content-type", "application/json;charset=utf-8") + self.send_header("connection", "close") + self.end_headers() + + invalid_auth = { + "ok": False, + "error": "invalid_auth", + } + + oauth_v2_access_response = """ +{ + "ok": true, + "access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", + "token_type": "bot", + "scope": "chat:write,commands", + "bot_user_id": "U0KRQLJ9H", + "app_id": "A0KRD7HC3", + "team": { + "name": "Slack Softball Team", + "id": "T9TK3CUKW" + }, + "enterprise": { + "name": "slack-sports", + "id": "E12345678" + }, + "authed_user": { + "id": "U1234", + "scope": "chat:write", + "access_token": "xoxp-1234", + "token_type": "user" + } +} +""" + oauth_v2_access_bot_refresh_response = """ + { + "ok": true, + "app_id": "A0KRD7HC3", + "access_token": "xoxb-valid-refreshed", + "expires_in": 43200, + "refresh_token": "xoxe-1-valid-bot-refreshed", + "token_type": "bot", + "scope": "chat:write,commands", + "bot_user_id": "U0KRQLJ9H", + "team": { + "name": "Slack Softball Team", + "id": "T9TK3CUKW" + }, + "enterprise": { + "name": "slack-sports", + "id": "E12345678" + } + } +""" + oauth_v2_access_user_refresh_response = """ + { + "ok": true, + "app_id": "A0KRD7HC3", + "access_token": "xoxp-valid-refreshed", + "expires_in": 43200, + "refresh_token": "xoxe-1-valid-user-refreshed", + "token_type": "user", + "scope": "search:read", + "team": { + "name": "Slack Softball Team", + "id": "T9TK3CUKW" + }, + "enterprise": { + "name": "slack-sports", + "id": "E12345678" + } + } + """ + bot_auth_test_response = """ +{ + "ok": true, + "url": "https://subarachnoid.slack.com/", + "team": "Subarachnoid Workspace", + "user": "bot", + "team_id": "T0G9PQBBK", + "user_id": "W23456789", + "bot_id": "BZYBOTHED" +} +""" + + user_auth_test_response = """ +{ + "ok": true, + "url": "https://subarachnoid.slack.com/", + "team": "Subarachnoid Workspace", + "user": "some-user", + "team_id": "T0G9PQBBK", + "user_id": "W99999" +} +""" + + 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: + if path == "/webhook": + self.send_response(200) + self.set_common_headers() + self.wfile.write("OK".encode("utf-8")) + return + + if path == "/received_requests.json": + self.send_response(200) + self.set_common_headers() + self.wfile.write(json.dumps(self.received_requests).encode("utf-8")) + return + + body = {"ok": True} + if path == "/oauth.v2.access": + if self.headers.get("authorization") is not None: + request_body = self._parse_request_body( + parsed_path=parsed_path, + content_len=int(self.headers.get("Content-Length") or 0), + ) + self.logger.info(f"request body: {request_body}") + + if request_body.get("grant_type") == "refresh_token": + refresh_token = request_body.get("refresh_token") + if refresh_token is not None: + if "bot-valid" in refresh_token: + self.send_response(200) + self.set_common_headers() + body = self.oauth_v2_access_bot_refresh_response + self.wfile.write(body.encode("utf-8")) + return + if "user-valid" in refresh_token: + self.send_response(200) + self.set_common_headers() + body = self.oauth_v2_access_user_refresh_response + self.wfile.write(body.encode("utf-8")) + return + elif request_body.get("code") is not None: + self.send_response(200) + self.set_common_headers() + self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) + return + + if self.is_valid_user_token(): + if path == "/auth.test": + self.send_response(200) + self.set_common_headers() + self.wfile.write(self.user_auth_test_response.encode("utf-8")) + return + + if self.is_valid_token(): + if path == "/auth.test": + self.send_response(200) + self.set_common_headers() + self.wfile.write(self.bot_auth_test_response.encode("utf-8")) + return + + request_body = self._parse_request_body( + parsed_path=parsed_path, + content_len=int(self.headers.get("Content-Length") or 0), + ) + self.logger.info(f"request: {path} {request_body}") + + header = self.headers["authorization"] + pattern = str(header).split("xoxb-", 1)[1] + if pattern.isnumeric(): + self.send_response(int(pattern)) + self.set_common_headers() + self.wfile.write("""{"ok":false}""".encode("utf-8")) + return + else: + body = self.invalid_auth + + 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() + + def _parse_request_body(self, parsed_path: str, content_len: int) -> Optional[dict]: + post_body = self.rfile.read(content_len) + request_body = None + if post_body: + try: + post_body = post_body.decode("utf-8") + if post_body.startswith("{"): + request_body = json.loads(post_body) + else: + request_body = {k: v[0] for k, v in parse_qs(post_body).items()} + except UnicodeDecodeError: + pass + else: + if parsed_path and parsed_path.query: + request_body = {k: v[0] for k, v in parse_qs(parsed_path.query).items()} + return request_body + + +# +# multiprocessing +# + + +class MockServerProcessTarget: + def __init__(self, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + self.handler = handler + + def run(self): + self.handler.received_requests = {} + self.server = HTTPServer(("localhost", 8888), self.handler) + try: + self.server.serve_forever(0.05) + finally: + self.server.server_close() + + def stop(self): + self.handler.received_requests = {} + self.server.shutdown() + self.join() + + +class MonitorThread(threading.Thread): + def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + threading.Thread.__init__(self, daemon=True) + self.handler = handler + self.test = test + self.test.mock_received_requests = None + self.is_running = True + + def run(self) -> None: + while self.is_running: + try: + req = Request(f"{self.test.server_url}/received_requests.json") + resp = urlopen(req, timeout=1) + self.test.mock_received_requests = json.loads(resp.read().decode("utf-8")) + except Exception as e: + # skip logging for the initial request + if self.test.mock_received_requests is not None: + logging.getLogger(__name__).exception(e) + time.sleep(0.01) + + def stop(self): + self.is_running = False + self.join() + + +# +# 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): + if get_mock_server_mode() == "threading": + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() + else: + # start a mock server as another process + target = MockServerProcessTarget() + test.server_url = "http://localhost:8888" + test.host, test.port = "localhost", 8888 + test.process = Process(target=target.run, daemon=True) + test.process.start() + + time.sleep(0.1) + + # start a thread in the current process + # this thread fetches mock_received_requests from the remote process + test.monitor_thread = MonitorThread(test) + test.monitor_thread.start() + count = 0 + # wait until the first successful data retrieval + while test.mock_received_requests is None: + time.sleep(0.01) + count += 1 + if count >= 100: + raise Exception("The mock server is not yet running!") + + +def cleanup_mock_web_api_server(test: TestCase): + if get_mock_server_mode() == "threading": + test.thread.stop() + test.thread = None + else: + # stop the thread to fetch mock_received_requests from the remote process + test.monitor_thread.stop() + + # terminate the process + test.process.terminate() + test.process.join() + + # Python 3.6 does not have these methods + if sys.version_info.major == 3 and sys.version_info.minor > 6: + # cleanup the process's resources + test.process.kill() + test.process.close() + + test.process = None + + +def assert_auth_test_count(test: TestCase, expected_count: int): + time.sleep(0.1) + retry_count = 0 + error = None + while retry_count < 3: + try: + test.mock_received_requests.get("/auth.test", 0) == expected_count + break + except Exception as e: + error = e + retry_count += 1 + # waiting for mock_received_requests updates + time.sleep(0.1) + + if error is not None: + raise error diff --git a/tests/scenario_test/test_get_hooks.py b/tests/scenario_test/test_get_hooks.py index 9a74591..9a4af3c 100644 --- a/tests/scenario_test/test_get_hooks.py +++ b/tests/scenario_test/test_get_hooks.py @@ -6,7 +6,6 @@ class TestGetHooks: def test_get_manifest(self, capsys): - runpy.run_module(get_hooks.__name__, run_name="__main__") out, err = capsys.readouterr() @@ -16,7 +15,6 @@ def test_get_manifest(self, capsys): 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() diff --git a/tests/scenario_test/test_get_manifest.py b/tests/scenario_test/test_get_manifest.py index 1d78aea..bc26522 100644 --- a/tests/scenario_test/test_get_manifest.py +++ b/tests/scenario_test/test_get_manifest.py @@ -14,7 +14,7 @@ def teardown_method(self): os.chdir(self.cwd) def test_get_manifest_script(self, capsys): - working_directory = "tests/scenario_tests/cli/test_app" + working_directory = "tests/scenario_test/test_app" os.chdir(working_directory) runpy.run_module(get_manifest.__name__, run_name="__main__") @@ -24,7 +24,7 @@ def test_get_manifest_script(self, capsys): assert {"_metadata": {}, "display_information": {"name": "Bolt app"}} == json.loads(out) def test_get_manifest_script_no_manifest(self): - working_directory = "tests/scenario_tests/cli/test_app_no_manifest" + working_directory = "tests/scenario_test/test_app_no_manifest" os.chdir(working_directory) with pytest.raises(CliError) as e: diff --git a/tests/scenario_test/test_start.py b/tests/scenario_test/test_start.py index efc73c8..e88f047 100644 --- a/tests/scenario_test/test_start.py +++ b/tests/scenario_test/test_start.py @@ -5,17 +5,18 @@ 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_tests/cli/test_app" + 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() @@ -24,6 +25,7 @@ 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) + cleanup_mock_web_api_server(self) stop_socket_mode_server(self) restore_os_env(self.old_os_env) @@ -49,7 +51,7 @@ def test_start_with_entrypoint(self, capsys, caplog): assert "INFO A new session has been established" in caplog.text def test_start_no_entrypoint(self): - working_directory = "tests/scenario_tests/cli" + working_directory = "tests/scenario_test/" os.chdir(working_directory) with pytest.raises(CliError) as e: diff --git a/tests/slack_cli_hooks/hooks/test_get_hooks.py b/tests/slack_cli_hooks/hooks/test_get_hooks.py index 564b759..8afb69a 100644 --- a/tests/slack_cli_hooks/hooks/test_get_hooks.py +++ b/tests/slack_cli_hooks/hooks/test_get_hooks.py @@ -5,8 +5,8 @@ class TestGetHooks: def test_hooks_payload(self): hooks = hooks_payload["hooks"] - assert "slack_bolt.cli.get_manifest" in hooks["get-manifest"] - assert "slack_bolt.cli.start" in hooks["start"] + 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"] diff --git a/tests/slack_cli_hooks/hooks/test_start.py b/tests/slack_cli_hooks/hooks/test_start.py index 5427400..949c432 100644 --- a/tests/slack_cli_hooks/hooks/test_start.py +++ b/tests/slack_cli_hooks/hooks/test_start.py @@ -11,7 +11,6 @@ class TestStart: - working_directory = "tests/slack_bolt/cli/test_app" def setup_method(self): From 1caccb55df9bc59a24938189337f89e67ef9c151 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 Nov 2023 15:39:05 -0500 Subject: [PATCH 6/7] clean up code --- tests/mock_socket_mode_server.py | 78 +++---------------- tests/mock_web_api_server.py | 124 ++----------------------------- tests/utils.py | 15 ---- 3 files changed, 15 insertions(+), 202 deletions(-) diff --git a/tests/mock_socket_mode_server.py b/tests/mock_socket_mode_server.py index 68ee0fc..2353286 100644 --- a/tests/mock_socket_mode_server.py +++ b/tests/mock_socket_mode_server.py @@ -1,16 +1,12 @@ import json import logging -import sys import threading import time from urllib.request import urlopen from urllib.error import URLError -from multiprocessing.context import Process from typing import List from unittest import TestCase -from tests.utils import get_mock_server_mode - socket_mode_envelopes = [ """{"envelope_id":"57d6a792-4d35-4d0b-b6aa-3361493e1caf","payload":{"type":"shortcut","token":"xxx","action_ts":"1610198080.300836","team":{"id":"T111","domain":"seratch"},"user":{"id":"U111","username":"seratch","team_id":"T111"},"is_enterprise_install":false,"enterprise":null,"callback_id":"do-something","trigger_id":"111.222.xxx"},"type":"interactive","accepts_response_payload":false}""", """{"envelope_id":"1d3c79ab-0ffb-41f3-a080-d19e85f53649","payload":{"token":"xxx","team_id":"T111","team_domain":"xxx","channel_id":"C111","channel_name":"random","user_id":"U111","user_name":"seratch","command":"/hello-socket-mode","text":"","api_app_id":"A111","response_url":"https://hooks.slack.com/commands/T111/111/xxx","trigger_id":"111.222.xxx"},"type":"slash_commands","accepts_response_payload":true}""", @@ -57,42 +53,11 @@ def link(ws): return _start_thread_socket_mode_server -def start_process_socket_mode_server(port: int): - logger = logging.getLogger(__name__) - app: Flask = Flask(__name__) - sockets: Sockets = Sockets(app) - - envelopes_to_consume: List[str] = list(socket_mode_envelopes) - - @sockets.route("/link") - def link(ws): - while not ws.closed: - message = ws.read_message() - if message is not None: - if len(envelopes_to_consume) > 0: - e = envelopes_to_consume.pop(0) - logger.debug(f"Send an envelope: {e}") - ws.send(e) - - logger.debug(f"Server received a message: {message}") - ws.send(message) - - from gevent import pywsgi - from geventwebsocket.handler import WebSocketHandler - - server = pywsgi.WSGIServer(("", port), app, handler_class=WebSocketHandler) - server.serve_forever(stop_timeout=1) - - def start_socket_mode_server(test, port: int): - if get_mock_server_mode() == "threading": - 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 - else: - test.sm_process = Process(target=start_process_socket_mode_server, kwargs={"port": port}) - test.sm_process.start() + 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): @@ -106,36 +71,11 @@ def wait_for_socket_mode_server(port: int, secs: int): def stop_socket_mode_server(test): - if get_mock_server_mode() == "threading": - print(test) - test.server.stop() - test.server.close() - else: - # terminate the process - test.sm_process.terminate() - test.sm_process.join() - # Python 3.6 does not have these methods - if sys.version_info.major == 3 and sys.version_info.minor > 6: - # cleanup the process's resources - test.sm_process.kill() - test.sm_process.close() - - test.sm_process = None + print(test) + test.server.stop() + test.server.close() async def stop_socket_mode_server_async(test: TestCase): - if get_mock_server_mode() == "threading": - test.server.stop() - test.server.close() - else: - # terminate the process - test.sm_process.terminate() - test.sm_process.join() - - # Python 3.6 does not have these methods - if sys.version_info.major == 3 and sys.version_info.minor > 6: - # cleanup the process's resources - test.sm_process.kill() - test.sm_process.close() - - test.sm_process = None + test.server.stop() + test.server.close() diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 91a67f1..522e10b 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -1,20 +1,12 @@ -import asyncio import json import logging -import sys import threading -import time from http import HTTPStatus from http.server import HTTPServer, SimpleHTTPRequestHandler from typing import Type, Optional from unittest import TestCase from urllib.parse import urlparse, parse_qs, ParseResult -from multiprocessing import Process -from urllib.request import urlopen, Request - -from tests.utils import get_mock_server_mode - class MockHandler(SimpleHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -234,54 +226,6 @@ def _parse_request_body(self, parsed_path: str, content_len: int) -> Optional[di return request_body -# -# multiprocessing -# - - -class MockServerProcessTarget: - def __init__(self, handler: Type[SimpleHTTPRequestHandler] = MockHandler): - self.handler = handler - - def run(self): - self.handler.received_requests = {} - self.server = HTTPServer(("localhost", 8888), self.handler) - try: - self.server.serve_forever(0.05) - finally: - self.server.server_close() - - def stop(self): - self.handler.received_requests = {} - self.server.shutdown() - self.join() - - -class MonitorThread(threading.Thread): - def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): - threading.Thread.__init__(self, daemon=True) - self.handler = handler - self.test = test - self.test.mock_received_requests = None - self.is_running = True - - def run(self) -> None: - while self.is_running: - try: - req = Request(f"{self.test.server_url}/received_requests.json") - resp = urlopen(req, timeout=1) - self.test.mock_received_requests = json.loads(resp.read().decode("utf-8")) - except Exception as e: - # skip logging for the initial request - if self.test.mock_received_requests is not None: - logging.getLogger(__name__).exception(e) - time.sleep(0.01) - - def stop(self): - self.is_running = False - self.join() - - # # threading # @@ -313,68 +257,12 @@ def stop(self): def setup_mock_web_api_server(test: TestCase): - if get_mock_server_mode() == "threading": - test.server_started = threading.Event() - test.thread = MockServerThread(test) - test.thread.start() - test.server_started.wait() - else: - # start a mock server as another process - target = MockServerProcessTarget() - test.server_url = "http://localhost:8888" - test.host, test.port = "localhost", 8888 - test.process = Process(target=target.run, daemon=True) - test.process.start() - - time.sleep(0.1) - - # start a thread in the current process - # this thread fetches mock_received_requests from the remote process - test.monitor_thread = MonitorThread(test) - test.monitor_thread.start() - count = 0 - # wait until the first successful data retrieval - while test.mock_received_requests is None: - time.sleep(0.01) - count += 1 - if count >= 100: - raise Exception("The mock server is not yet running!") + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() def cleanup_mock_web_api_server(test: TestCase): - if get_mock_server_mode() == "threading": - test.thread.stop() - test.thread = None - else: - # stop the thread to fetch mock_received_requests from the remote process - test.monitor_thread.stop() - - # terminate the process - test.process.terminate() - test.process.join() - - # Python 3.6 does not have these methods - if sys.version_info.major == 3 and sys.version_info.minor > 6: - # cleanup the process's resources - test.process.kill() - test.process.close() - - test.process = None - - -def assert_auth_test_count(test: TestCase, expected_count: int): - time.sleep(0.1) - retry_count = 0 - error = None - while retry_count < 3: - try: - test.mock_received_requests.get("/auth.test", 0) == expected_count - break - except Exception as e: - error = e - retry_count += 1 - # waiting for mock_received_requests updates - time.sleep(0.1) - - if error is not None: - raise error + test.thread.stop() + test.thread = None diff --git a/tests/utils.py b/tests/utils.py index 733a7c4..eb9759c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -15,21 +15,6 @@ def restore_os_env(old_env: dict) -> None: os.environ.update(old_env) -def get_mock_server_mode() -> str: - """Returns a str representing the mode. - - :return: threading/multiprocessing - """ - mode = os.environ.get("BOLT_PYTHON_MOCK_SERVER_MODE") - if mode is None: - # We used to use "multiprocessing"" for macOS until Big Sur 11.1 - # Since 11.1, the "multiprocessing" mode started failing a lot... - # Therefore, we switched the default mode back to "threading". - return "threading" - else: - return mode - - def get_event_loop(): try: return asyncio.get_event_loop() From 998d35f8bbc84e822a30b4511cd52a0fc30ac944 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 Nov 2023 17:07:11 -0500 Subject: [PATCH 7/7] Add .github --- .github/CODE_OF_CONDUCT.md | 11 ++ .github/contributing.md | 11 ++ .github/maintainers_guide.md | 209 ++++++++++++++++++++++++++++ .github/workflows/codecov.yml | 39 ++++++ .github/workflows/flake8.yml | 23 +++ .github/workflows/pytype.yml | 23 +++ .github/workflows/tests.yml | 33 +++++ .github/workflows/triage-issues.yml | 33 +++++ scripts/install_and_run_tests.sh | 2 +- scripts/run_tests.sh | 2 +- slack_cli_hooks/__init__.py | 9 +- 11 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/contributing.md create mode 100644 .github/maintainers_guide.md create mode 100644 .github/workflows/codecov.yml create mode 100644 .github/workflows/flake8.yml create mode 100644 .github/workflows/pytype.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .github/workflows/triage-issues.yml diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..251a4ca --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,11 @@ +# Code of Conduct + +## Introduction + +Diversity and inclusion make our community strong. We encourage participation from the most varied and diverse backgrounds possible and want to be very clear about where we stand. + +Our goal is to maintain a safe, helpful and friendly community for everyone, regardless of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other defining characteristic. + +This code and related procedures also apply to unacceptable behavior occurring outside the scope of community activities, in all community venues (online and in-person) as well as in all one-on-one communications, and anywhere such behavior has the potential to adversely affect the safety and well-being of community members. + +For more information on our code of conduct, please visit [https://slackhq.github.io/code-of-conduct](https://slackhq.github.io/code-of-conduct) \ No newline at end of file diff --git a/.github/contributing.md b/.github/contributing.md new file mode 100644 index 0000000..0726ebf --- /dev/null +++ b/.github/contributing.md @@ -0,0 +1,11 @@ +# Contributors Guide + +Interested in contributing? Awesome! Before you do though, please read our +[Code of Conduct](https://slackhq.github.io/code-of-conduct). We take it very seriously, and expect that you will as +well. + + + +## Maintainers + +There are more details about processes and workflow in the [Maintainer's Guide](./maintainers_guide.md). diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md new file mode 100644 index 0000000..29d2038 --- /dev/null +++ b/.github/maintainers_guide.md @@ -0,0 +1,209 @@ +# Maintainers Guide + + + +This document describes tools, tasks and workflow that one needs to be familiar with in order to effectively maintain +this project. If you use this package within your own software as is but don't plan on modifying it, this guide is +**not** for you. + +## Tools + +### Python (and friends) + +We recommend using [pyenv](https://github.com/pyenv/pyenv) for Python runtime management. If you use macOS, follow the following steps: + +```zsh +brew update +brew install pyenv +``` + +Install necessary Python runtimes for development/testing. You can rely on GitHub Action for testing with various major versions. + +```zsh +$ pyenv install -l | grep -v "-e[conda|stackless|pypy]" + +$ pyenv install 3.8.5 # select the latest patch version +$ pyenv local 3.8.5 + +$ pyenv versions + system + 3.6.10 + 3.7.7 +* 3.8.5 (set by /path-to-python-slack-hooks/.python-version) + +$ pyenv rehash +``` + +Then, you can create a new Virtual Environment this way: + +```zsh +python -m venv env_3.8.5 +source env_3.8.5/bin/activate +``` + +## Tasks + +### Testing + +#### Run All the Unit Tests + +If you make some changes to this project, please write corresponding unit tests as much as possible. You can easily run all the tests by running the following script. + +If this is your first time to run tests, although it may take a bit long time, running the following script is the easiest. + +```zsh +./scripts/install_all_and_run_tests.sh +``` + +Once you installed all the required dependencies, you can use the following one. + +```zsh +./scripts/run_tests.sh +``` + +Also, you can run a single test this way. + +```zsh +./scripts/run_tests.sh tests/scenario_test/test_get_hooks.py +``` + +#### Develop Locally + +If you want to test the package locally you can. + +1. Build the package locally + - Run + + ```zsh + scripts/build_pypi_package.sh + ``` + + - This will create a `.whl` file in the `./dist` folder +2. Use the built package + - Example `/dist/slack_bolt-1.2.3-py2.py3-none-any.whl` was created + - From anywhere on your machine you can install this package to a project with + + ```zsh + pip install /dist/slack_bolt-1.2.3-py2.py3-none-any.whl + ``` + + - It is also possible to include `/dist/slack_bolt-1.2.3-py2.py3-none-any.whl` in a [requirements.txt](https://pip.pypa.io/en/stable/user_guide/#requirements-files) file + +### Releasing + +#### test.pypi.org deployment + +```zsh +./scripts/deploy_to_test_pypi.sh +``` + +##### $HOME/.pypirc + +```txt +[testpypi] +username: {your username} +password: {your password} +``` + +#### Development Deployment + +1. Create a branch in which the development release will live: + - Bump the version number in adherence to [Semantic Versioning](http://semver.org/) and [Developmental Release](https://peps.python.org/pep-0440/#developmental-releases) in `slack_bolt/version.py` + - Example the current version is `1.2.3` a proper development bump would be `1.3.0.dev0` + - `.dev` will indicate to pip that this is a [Development Release](https://peps.python.org/pep-0440/#developmental-releases) + - Note that the `dev` version can be bumped in development releases: `1.3.0.dev0` -> `1.3.0.dev1` + - Commit with a message including the new version number. For example `1.3.0.dev0` & Push the commit to a branch where the development release will live (create it if it does not exist) + - `git checkout -b future-release` + - `git commit -m 'version 1.3.0.dev0'` + - `git push future-release` + - Create a git tag for the release. For example `git tag v1.3.0.dev0`. + - Push the tag up to github with `git push origin --tags` + +2. Distribute the release + - Use the latest stable Python runtime + - `python -m venv .venv` + - `./scripts/deploy_to_pypi_org.sh` + - You do not need to create a GitHub release + +3. (Slack Internal) Communicate the release internally + +#### Production Deployment + +1. Create the commit for the release: + - Bump the version number in adherence to [Semantic Versioning](http://semver.org/) in `slack_bolt/version.py` + - Commit with a message including the new version number. For example `1.2.3` & Push the commit to a branch and create a PR to sanity check. + - `git checkout -b v1.2.3-release` + - `git commit -m 'version 1.2.3'` + - `git push {your-fork} v1.2.3-release` + - Merge in release PR after getting an approval from at least one maintainer. + - Create a git tag for the release. For example `git tag v1.2.3`. + - Push the tag up to github with `git push origin --tags` + +2. Distribute the release + - Use the latest stable Python runtime + - `python -m venv .venv` + - `./scripts/deploy_to_pypi_org.sh` + - Create a GitHub release - + + ```markdown + ## New Features + + ### Awesome Feature 1 + + Description here. + + ### Awesome Feature 2 + + Description here. + + ## Changes + + * #123 Make it better - thanks @SlackHQ + * #123 Fix something wrong - thanks @seratch + ``` + +3. (Slack Internal) Communicate the release internally + - Include a link to the GitHub release + +4. Make announcements + - #tools-bolt in community.slack.com + +5. (Slack Internal) Tweet by @SlackAPI + - Not necessary for patch updates, might be needed for minor updates, definitely needed for major updates. Include a link to the GitHub release + +## Workflow + +### Versioning and Tags + +This project uses semantic versioning, expressed through the numbering scheme of +[PEP-0440](https://www.python.org/dev/peps/pep-0440/). + +### Branches + +`main` is where active development occurs. Long running named feature branches are occasionally created for +collaboration on a feature that has a large scope (because everyone cannot push commits to another person's open Pull +Request). At some point in the future after a major version increment, there may be maintenance branches for older major +versions. + +### Issue Management + +Labels are used to run issues through an organized workflow. Here are the basic definitions: + +- `bug`: A confirmed bug report. A bug is considered confirmed when reproduction steps have been + documented and the issue has been reproduced. +- `enhancement`: A feature request for something this package might not already do. +- `docs`: An issue that is purely about documentation work. +- `tests`: An issue that is purely about testing work. +- `discussion`: An issue that is purely meant to hold a discussion. Typically the maintainers are looking for feedback in this issues. +- `question`: An issue that is like a support request because the user's usage was not correct. + +**Triage** is the process of taking new issues that aren't yet "seen" and marking them with a basic level of information +with labels. An issue should have **one** of the following labels applied: `bug`, `enhancement`, `question`, +`needs feedback`, `docs`, `tests`, or `discussion`. + +Issues are closed when a resolution has been reached. If for any reason a closed issue seems relevant once again, +reopening is great and better than creating a duplicate issue. + +## Everything else + +When in doubt, find the other maintainers and ask. diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..ed5bddc --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,39 @@ +# TODO: This CI job hangs as of April 2023 +name: Run codecov + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ["3.12"] + env: + # default: multiprocessing + # threading is more stable on GitHub Actions + BOLT_PYTHON_MOCK_SERVER_MODE: threading + BOLT_PYTHON_CODECOV_RUNNING: "1" + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -U pip + python install . + pip install -r requirements/testing.txt + - name: Run all tests for codecov + run: | + pytest --cov=./slack_cli_hooks/ --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true + verbose: true diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml new file mode 100644 index 0000000..f0eb9c5 --- /dev/null +++ b/.github/workflows/flake8.yml @@ -0,0 +1,23 @@ +name: Run flake8 validation + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Run flake8 verification + run: | + ./scripts/run_flake8.sh diff --git a/.github/workflows/pytype.yml b/.github/workflows/pytype.yml new file mode 100644 index 0000000..5aaa502 --- /dev/null +++ b/.github/workflows/pytype.yml @@ -0,0 +1,23 @@ +name: Run pytype validation + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Run pytype verification + run: | + ./scripts/run_pytype.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d6d94cf --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Run all the unit tests + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + # Avoiding -latest due to https://github.com/actions/setup-python/issues/162 + runs-on: ubuntu-20.04 + timeout-minutes: 10 + strategy: + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + env: + # default: multiprocessing + # threading is more stable on GitHub Actions + BOLT_PYTHON_MOCK_SERVER_MODE: threading + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -U pip + python install . + pip install -r requirements/testing.txt + - name: Run tests + run: | + pytest diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml new file mode 100644 index 0000000..cf6864a --- /dev/null +++ b/.github/workflows/triage-issues.yml @@ -0,0 +1,33 @@ +# This workflow uses the following github action to automate +# management of stale issues and prs in this repo: +# https://github.com/marketplace/actions/close-stale-issues + +name: Close stale issues and PRs + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 1' + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v4.0.0 + with: + days-before-issue-stale: 30 + days-before-issue-close: 10 + days-before-pr-stale: -1 + days-before-pr-close: -1 + stale-issue-label: auto-triage-stale + stale-issue-message: 👋 It looks like this issue has been open for 30 days with no activity. We'll mark this as stale for now, and wait 10 days for an update or for further comment before closing this issue out. If you think this issue needs to be prioritized, please comment to get the thread going again! Maintainers also review issues marked as stale on a regular basis and comment or adjust status if the issue needs to be reprioritized. + close-issue-message: As this issue has been inactive for more than one month, we will be closing it. Thank you to all the participants! If you would like to raise a related issue, please create a new issue which includes your specific details and references this issue number. + exempt-issue-labels: auto-triage-skip + exempt-all-milestones: true + remove-stale-when-updated: true + enable-statistics: true + operations-per-run: 60 \ No newline at end of file diff --git a/scripts/install_and_run_tests.sh b/scripts/install_and_run_tests.sh index 32a49eb..98a4ad4 100755 --- a/scripts/install_and_run_tests.sh +++ b/scripts/install_and_run_tests.sh @@ -11,5 +11,5 @@ then pytest $1 else pytest && \ - pytype slack_cli_hooks/ + pytype slack_cli_hooks/ fi diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 996724e..839dee9 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -13,5 +13,5 @@ if [[ $test_target != "" ]] then pytest -vv $1 else - pytest tests/* + pytest fi diff --git a/slack_cli_hooks/__init__.py b/slack_cli_hooks/__init__.py index 395da3d..d76dc03 100644 --- a/slack_cli_hooks/__init__.py +++ b/slack_cli_hooks/__init__.py @@ -1,11 +1,10 @@ """ -A Python framework to build Slack apps in a flash with the latest platform features.Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. +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. -* Website: https://slack.dev/bolt-python/ -* GitHub repository: https://github.com/slackapi/bolt-python -* The class representing a Bolt app: `slack_bolt.app.app` +* 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 -# Don't add async module imports here from .hooks import get_hooks, get_manifest, start __all__ = [