diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index 14b1f3d..b8ace89 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -20,14 +20,14 @@ Install necessary Python runtimes for development/testing. You can rely on GitHu ```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 install 3.9.18 # select the latest patch version +pyenv local 3.9.18 pyenv versions system 3.6.10 3.7.7 -* 3.8.5 (set by /path-to-python-slack-hooks/.python-version) +* 3.9.18 (set by /path-to-python-slack-hooks/.python-version) pyenv rehash ``` @@ -35,8 +35,8 @@ 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 +python -m venv env_3.9.18 +source env_3.9.18/bin/activate ``` ## Tasks diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 671a84c..07528d5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: timeout-minutes: 5 strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "pypy3.10"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/README.md b/README.md index 6b3164c..de153df 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,64 @@

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/) +

+ + PyPI - Version + + Python Versions +

-## Environment requirements +This library defines the contract between the +[Slack CLI](https://api.slack.com/automation/cli/install) and +[Bolt for Python](https://slack.dev/bolt-python/). -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).** +## Overview -### Install the Slack CLI +This library enables inter-process communication between the [Slack CLI](https://api.slack.com/automation/cli/install) and applications built with Bolt for Python. -Install the Slack CLI. Step-by-step instructions can be found in this -[Quickstart Guide][slack-cli-docs]. +When used together, the CLI delegates various tasks to the Bolt application by invoking processes ("hooks") and then making use of the responses provided by each hook's `stdout`. -### Environment Setup +For a complete list of available hooks, read the [Supported Hooks](#supported-hooks) section. -Create a project folder and a -[virtual environment](https://docs.python.org/3/library/venv.html#module-venv) -within it +## Requirements -```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 -``` +The latest minor version of [Bolt v1](https://pypi.org/project/slack-bolt/) is recommended. -### Clone +## Usage -Clone this project using git. +A Slack CLI-compatible Slack application includes a `./slack.json` file that contains hooks specific to that project. Each hook is associated with commands that are available in the Slack CLI. By default, `get-hooks` retrieves all of the [supported hooks](#supported-hooks) and their corresponding scripts as defined in this library. -```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. +The CLI will always use the version of the `python-slack-hooks` that is specified in the project's `requirements.txt`. -## Simple project +### Supported Hooks -In the same directory where we installed `slack-cli-hooks` +The hooks currently supported for use within the Slack CLI include `check-update`, `get-hooks`, `get-manifest`, and `start`: -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. +| Hook Name | CLI Command | File | Description | +| --- | --- | --- | --- | +| `check-update` | `slack update` | [check_update.py](./slack_cli_hooks/hooks/check_update.py) | Checks the project's Slack dependencies to determine whether or not any libraries need to be updated. | +| `get-hooks` | All | [get_hooks.py](./slack_cli_hooks/hooks/get_hooks.py) | Fetches the list of available hooks for the CLI from this repository. | +| `get-manifest` | `slack manifest` | [get_manifest.py](./slack_cli_hooks/hooks/get_manifest.py) | Converts a `manifest.json` file into a valid manifest JSON payload. | +| `start` | `slack run` | [start.py](./slack_cli_hooks/hooks/start.py) | While developing locally, the CLI manages a socket connection with Slack's backend and utilizes this hook for events received via this connection. | -### Application Configuration +### Overriding Hooks -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"] - } - } -} -``` +To customize the behavior of a hook, add the hook to your application's `/slack.json` file, and provide a corresponding script to be executed. -### CLI/Bolt Interface Configuration +When commands are run, the Slack CLI will look to the project's hook definitions and use those instead of what's defined in this library, if provided. -Define the Slack CLI configuration in a file named `slack.json`. +Below is an example `/slack.json` file that overrides the default `start`: ```json { "hooks": { - "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks" + "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks", + "start": "python3 app.py" } } ``` -### 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 +Contributions are always welcome! Please review 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 +for more information. diff --git a/pyproject.toml b/pyproject.toml index 4067b3d..6b1771a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools", "pytest-runner"] +requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] @@ -8,7 +8,7 @@ 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" +requires-python = ">=3.9" classifiers = [ "Development Status :: 2 - Pre-Alpha", "Environment :: Console", @@ -18,9 +18,6 @@ classifiers = [ "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", diff --git a/requirements/format.txt b/requirements/format.txt index 8df1850..a0c3308 100644 --- a/requirements/format.txt +++ b/requirements/format.txt @@ -1,5 +1,4 @@ -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 +black 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/scripts/_utils.sh b/scripts/_utils.sh index f41d7aa..73ee0b6 100644 --- a/scripts/_utils.sh +++ b/scripts/_utils.sh @@ -10,16 +10,8 @@ clean_project() { } install_development_requirements() { - 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 -U pip + pip install -e . pip install -r requirements/testing.txt pip install -r requirements/format.txt } diff --git a/slack_cli_hooks/error/__init__.py b/slack_cli_hooks/error/__init__.py index 570528a..bd4efb1 100644 --- a/slack_cli_hooks/error/__init__.py +++ b/slack_cli_hooks/error/__init__.py @@ -1,2 +1,6 @@ class CliError(Exception): """General class for cli error""" + + +class PypiError(Exception): + """General class for PyPI package info retrieval error""" diff --git a/slack_cli_hooks/hooks/check_update.py b/slack_cli_hooks/hooks/check_update.py new file mode 100644 index 0000000..61475f9 --- /dev/null +++ b/slack_cli_hooks/hooks/check_update.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +import json +from http.client import HTTPResponse +from types import ModuleType +from typing import Any, Dict, List, Optional +from urllib import request + +import slack_bolt +import slack_sdk +from packaging.version import Version + +import slack_cli_hooks.version +from slack_cli_hooks.error import PypiError +from slack_cli_hooks.protocol import Protocol, build_protocol + +PROTOCOL: Protocol + +DEPENDENCIES: List[ModuleType] = [slack_cli_hooks, slack_bolt, slack_sdk] + + +class Release: + def __init__( + self, + name: str, + current: Optional[Version] = None, + latest: Optional[Version] = None, + message: Optional[str] = None, + url: Optional[str] = None, + error: Optional[Dict[str, str]] = None, + ): + self.name = name + if current and latest: + self.current = current.base_version + self.latest = latest.base_version + self.update = current < latest + self.breaking = (current.major - latest.major) != 0 + if error: + self.error = error + if message: + self.message = message + if url: + self.url = url + + +def pypi_get(project: str, headers={"Accept": "application/json"}) -> HTTPResponse: + # Based on https://warehouse.pypa.io/api-reference/json.html + url = f"https://pypi.org/pypi/{project}/json" + pypi_request = request.Request(method="GET", url=url, headers=headers) + return request.urlopen(pypi_request) + + +def pypi_get_json(project: str) -> Dict[str, Any]: + pypi_response = pypi_get(project) + charset = pypi_response.headers.get_content_charset() or "utf-8" + raw_body = pypi_response.read().decode(charset) + if pypi_response.status > 200: + PROTOCOL.debug(f"Received status {pypi_response.status} from {pypi_response.url}") + PROTOCOL.debug(f"Headers {dict(pypi_response.getheaders())}") + PROTOCOL.debug(f"Body {raw_body}") + raise PypiError(f"Received status {pypi_response.status} from {pypi_response.url}") + return json.loads(raw_body) + + +def extract_latest_version(payload: Dict[str, Any]) -> str: + if "info" not in payload: + raise PypiError("Missing `info` field in pypi payload") + if "version" not in payload["info"]: + raise PypiError("Missing `version` field in pypi payload['info']") + return payload["info"]["version"] + + +def build_release(dependency: ModuleType) -> Release: + name = dependency.__name__ + try: + pypi_json_payload = pypi_get_json(name) + return Release( + name=name, + current=Version(dependency.version.__version__), + latest=Version(extract_latest_version(pypi_json_payload)), + ) + except PypiError as e: + return Release(name=name, error={"message": str(e)}) + + +def build_output(dependencies: List[ModuleType] = DEPENDENCIES) -> Dict[str, Any]: + output = {"name": "Slack Bolt", "url": "https://api.slack.com/automation/changelog", "releases": []} + errors = [] + + for dep in dependencies: + release = build_release(dep) + output["releases"].append(vars(release)) + + if hasattr(release, "error"): + errors.append(release.name) + + if errors: + output["error"] = {"message": f"An error occurred fetching updates for the following packages: {', '.join(errors)}"} + return output + + +if __name__ == "__main__": + PROTOCOL = build_protocol() + PROTOCOL.respond(json.dumps(build_output())) diff --git a/slack_cli_hooks/hooks/get_hooks.py b/slack_cli_hooks/hooks/get_hooks.py index df0a36a..1300a86 100644 --- a/slack_cli_hooks/hooks/get_hooks.py +++ b/slack_cli_hooks/hooks/get_hooks.py @@ -1,6 +1,11 @@ #!/usr/bin/env python import json -from slack_cli_hooks.protocol import Protocol, MessageBoundaryProtocol, DefaultProtocol, build_protocol +from slack_cli_hooks.protocol import ( + Protocol, + MessageBoundaryProtocol, + DefaultProtocol, + build_protocol, +) PROTOCOL: Protocol EXEC = "python3" @@ -10,6 +15,7 @@ "hooks": { "get-manifest": f"{EXEC} -m slack_cli_hooks.hooks.get_manifest", "start": f"{EXEC} -X dev -m slack_cli_hooks.hooks.start", + "check-update": f"{EXEC} -m slack_cli_hooks.hooks.check_update", }, "config": { "watch": {"filter-regex": "(^manifest\\.json$)", "paths": ["."]}, diff --git a/slack_cli_hooks/hooks/utils/managed_os_env_vars.py b/slack_cli_hooks/hooks/utils/managed_os_env_vars.py index 8cd3f75..917da04 100644 --- a/slack_cli_hooks/hooks/utils/managed_os_env_vars.py +++ b/slack_cli_hooks/hooks/utils/managed_os_env_vars.py @@ -10,9 +10,7 @@ def __init__(self, protocol: Protocol) -> None: 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!" - ) + 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 diff --git a/slack_cli_hooks/version.py b/slack_cli_hooks/version.py index e10a723..9d28a05 100644 --- a/slack_cli_hooks/version.py +++ b/slack_cli_hooks/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-cli-hooks/""" -__version__ = "0.0.0.dev0" +__version__ = "0.0.0.dev2" diff --git a/tests/scenario_test/test_check_update.py b/tests/scenario_test/test_check_update.py new file mode 100644 index 0000000..9f64d2a --- /dev/null +++ b/tests/scenario_test/test_check_update.py @@ -0,0 +1,47 @@ +from unittest.mock import patch +from urllib import request + +from slack_cli_hooks.hooks import check_update +from slack_cli_hooks.hooks.check_update import build_output +from slack_cli_hooks.protocol.default_protocol import DefaultProtocol +from tests.utils import build_fake_dependency, build_fake_pypi_urlopen + + +class TestGetManifest: + def setup_method(self): + check_update.PROTOCOL = DefaultProtocol() + + def test_build_output(self): + test_project = "test_proj" + fake_pypi_urlopen = build_fake_pypi_urlopen(status=200, body={"info": {"version": "0.0.1"}}) + test_dependency = build_fake_dependency(test_project, "0.0.0") + + with patch.object(request, "urlopen") as mock_urlopen: + mock_urlopen.side_effect = fake_pypi_urlopen + actual = build_output([test_dependency]) + + assert actual["name"] == "Slack Bolt" + assert len(actual["releases"]) == 1 + assert actual["releases"][0]["name"] == test_project + assert actual["releases"][0]["current"] == "0.0.0" + assert actual["releases"][0]["latest"] == "0.0.1" + assert actual["releases"][0]["update"] is True + assert actual["releases"][0]["breaking"] is False + assert "error" not in actual["releases"][0] + + def test_build_output_error(self): + test_project = "test_proj" + fake_pypi_urlopen = build_fake_pypi_urlopen(status=200, body={"info": {}}) + test_dependency = build_fake_dependency(test_project, "0.0.0") + + with patch.object(request, "urlopen") as mock_urlopen: + mock_urlopen.side_effect = fake_pypi_urlopen + actual = build_output([test_dependency]) + + assert actual["name"] == "Slack Bolt" + assert len(actual["releases"]) == 1 + assert actual["releases"][0]["name"] == test_project + assert "error" in actual["releases"][0] + assert "message" in actual["releases"][0]["error"] + assert "error" in actual + assert "message" in actual["error"] diff --git a/tests/slack_cli_hooks/hooks/test_check_update.py b/tests/slack_cli_hooks/hooks/test_check_update.py new file mode 100644 index 0000000..af28dee --- /dev/null +++ b/tests/slack_cli_hooks/hooks/test_check_update.py @@ -0,0 +1,111 @@ +from unittest.mock import patch +from urllib import request + +import pytest + +from slack_cli_hooks.error import PypiError +from slack_cli_hooks.hooks import check_update +from slack_cli_hooks.hooks.check_update import ( + build_output, + build_release, + extract_latest_version, + pypi_get, + pypi_get_json, +) +from slack_cli_hooks.protocol.default_protocol import DefaultProtocol +from tests.utils import build_fake_dependency, build_fake_pypi_urlopen + + +class TestGetManifest: + def setup_method(self): + check_update.PROTOCOL = DefaultProtocol() + + def test_pypi_get(self): + test_project = "test_proj" + fake_pypi_urlopen = build_fake_pypi_urlopen() + + with patch.object(request, "urlopen") as mock_urlopen: + mock_urlopen.side_effect = fake_pypi_urlopen + response = pypi_get(test_project) + + assert response.url == f"https://pypi.org/pypi/{test_project}/json" + assert response.status == 200 + assert response.read().decode("utf-8") == "{}" + + def test_pypi_get_json(self): + project = "my_test_project" + fake_pypi_urlopen = build_fake_pypi_urlopen(body={"info": {}, "releases": {}}) + + with patch.object(request, "urlopen") as mock_urlopen: + mock_urlopen.side_effect = fake_pypi_urlopen + json_response = pypi_get_json(project) + + assert json_response == {"info": {}, "releases": {}} + + def test_pypi_get_json_fail(self): + project = "my_test_project" + fake_pypi_urlopen = build_fake_pypi_urlopen(status=300) + + with patch.object(request, "urlopen") as mock_urlopen: + mock_urlopen.side_effect = fake_pypi_urlopen + with pytest.raises(PypiError) as e: + pypi_get_json(project) + + assert "300" in str(e) + assert f"https://pypi.org/pypi/{project}/json" in str(e) + + def test_extract_latest_version(self): + test_payload = {"info": {"version": "0.0.0"}} + actual = extract_latest_version(test_payload) + assert actual == "0.0.0" + + def test_extract_latest_version_missing_info(self): + test_payload = {} + with pytest.raises(PypiError) as e: + extract_latest_version(test_payload) + assert "info" in str(e) + + def test_extract_latest_version_missing_version(self): + test_payload = {"info": {}} + with pytest.raises(PypiError) as e: + extract_latest_version(test_payload) + assert "version" in str(e) + assert "payload['info']" in str(e) + + def test_build_release(self): + test_project = "test-dependency" + test_dependency = build_fake_dependency(test_project, "0.0.0") + + with patch.object(check_update, pypi_get_json.__name__) as mock_pypi_get_json: + mock_pypi_get_json.return_value = {"info": {"version": "0.0.1"}} + actual = build_release(test_dependency) + + assert vars(actual) == { + "name": test_project, + "current": "0.0.0", + "latest": "0.0.1", + "update": True, + "breaking": False, + } + + def test_build_release_error(self): + test_project = "test-dependency" + test_dependency = build_fake_dependency(test_project, "0.0.0") + + with patch.object(check_update, pypi_get_json.__name__) as mock_pypi_get_json: + mock_pypi_get_json.return_value = {} + actual = build_release(test_dependency) + + assert vars(actual) == { + "name": test_project, + "error": {"message": "Missing `info` field in pypi payload"}, + } + + def test_build_output(self): + actual = build_output([]) + + assert actual == { + "name": "Slack Bolt", + "url": "https://api.slack.com/automation/changelog", + "releases": [], + } diff --git a/tests/slack_cli_hooks/hooks/test_get_hooks.py b/tests/slack_cli_hooks/hooks/test_get_hooks.py index 2486865..b1e77f5 100644 --- a/tests/slack_cli_hooks/hooks/test_get_hooks.py +++ b/tests/slack_cli_hooks/hooks/test_get_hooks.py @@ -8,6 +8,7 @@ def test_hooks_payload(self): assert "slack_cli_hooks.hooks.get_manifest" in hooks["get-manifest"] assert "slack_cli_hooks.hooks.start" in hooks["start"] + assert "slack_cli_hooks.hooks.check_update" in hooks["check-update"] def test_hooks_payload_config(self): config = hooks_payload["config"] diff --git a/tests/utils.py b/tests/utils.py index 185e41b..39ae6f1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,4 +1,9 @@ +import json import os +from http.client import HTTPMessage, HTTPResponse +from typing import Callable, Union +from unittest.mock import MagicMock +from urllib.request import Request def remove_os_env_temporarily() -> dict: @@ -9,3 +14,25 @@ def remove_os_env_temporarily() -> dict: def restore_os_env(old_env: dict) -> None: os.environ.update(old_env) + + +def build_fake_pypi_urlopen(status: int = 200, headers=HTTPMessage(), body={}) -> Callable[..., HTTPResponse]: + headers.add_header("Content-Type", 'application/json; charset="UTF-8"') + + mock_resp = HTTPResponse(MagicMock()) + mock_resp.headers = headers + mock_resp.status = status + mock_resp.read = MagicMock(return_value=json.dumps(body).encode("UTF-8")) + + def fake_urlopen(url: Union[str, Request]): + mock_resp.url = url.full_url if isinstance(url, Request) else url + return mock_resp + + return fake_urlopen + + +def build_fake_dependency(name: str, version: str): + fake_dependency = MagicMock() + fake_dependency.version.__version__ = version + fake_dependency.__name__ = name + return fake_dependency