Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement basic hook logic #4

Merged
merged 26 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0da94c2
Add logic and tests
WilliamBergamin Dec 4, 2023
c6c1b31
fix module
WilliamBergamin Dec 5, 2023
a0782ab
update project
WilliamBergamin Dec 5, 2023
a341b14
clean test utils
WilliamBergamin Dec 5, 2023
8023815
Fix CI/CD for py 3.6
WilliamBergamin Dec 5, 2023
d69899e
fix typo
WilliamBergamin Dec 5, 2023
3ea0ee0
update flake8 version
WilliamBergamin Dec 6, 2023
0342720
Update README.md
WilliamBergamin Dec 6, 2023
02ef015
Update README.md
WilliamBergamin Dec 6, 2023
b8dd232
Simplify socketmode mock
WilliamBergamin Dec 6, 2023
e94d26e
Clean up mock web api server
WilliamBergamin Dec 6, 2023
a9295e2
Update README.md
WilliamBergamin Dec 6, 2023
3fe2726
Update README.md
WilliamBergamin Dec 6, 2023
dd7114d
Update README.md
WilliamBergamin Dec 6, 2023
8c937ec
Update README.md
WilliamBergamin Dec 6, 2023
794e7f4
Fix readme and watch
WilliamBergamin Dec 7, 2023
a3d272b
Remove .slack requirement
WilliamBergamin Dec 7, 2023
a3531f8
Adde watch regex test
WilliamBergamin Dec 7, 2023
d076e86
Improve the readme based on feedback
WilliamBergamin Dec 11, 2023
904b489
Fix dev mode
WilliamBergamin Dec 12, 2023
50daa49
Simplify example
WilliamBergamin Dec 12, 2023
4965260
Improve function naming
WilliamBergamin Dec 12, 2023
11c8984
Improv env var handling
WilliamBergamin Dec 12, 2023
309018e
Update tests/slack_cli_hooks/hooks/utils/test_managed_os_env_vars.py
WilliamBergamin Dec 13, 2023
c452feb
Update managed_os_env_vars.py
WilliamBergamin Dec 13, 2023
0744de6
Merge branch 'hooks-logic' of https://github.com/slackapi/python-slac…
WilliamBergamin Dec 13, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Install dependencies
run: |
pip install -U pip
pip install .
pip install -r requirements.txt
pip install -r requirements/testing.txt
- name: Run tests
run: |
Expand Down
101 changes: 100 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,101 @@
# python-slack-hooks
Helper library implementing the contract between the Slack CLI and Bolt for Python

Helper library implementing the contract between the [Slack CLI](https://api.slack.com/automation/cli) and [Bolt for Python](https://slack.dev/bolt-python/)

## Setup
WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved

Before getting started, make sure you have a development workspace where you have permissions to install apps. If you don’t have one set up, go ahead and create one.
WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved

### Install the Slack CLI

Install the Slack CLI. Step-by-step instructions can be found in this [Quickstart Guide](https://api.slack.com/automation/cli).

### Install this package

```bash
# Python 3.6+ required
python -m venv .venv
source .venv/bin/activate

pip install -U pip
pip install slack-cli-hooks
```

WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved
## Creating a Bolt app

Next, we will create a Bolt for Python app in the same directory where we installed `slack-cli-hooks`, define basic information and metadata about our app via an [App Manifest](https://api.slack.com/reference/manifests) (`manifest.json`) and finally create a `slack.json` file that defines the interface between the [Slack CLI](https://api.slack.com/automation/cli) and [Bolt for Python](https://slack.dev/bolt-python/concepts).

### Source code

Create a [Bolt for Python](https://slack.dev/bolt-python/concepts) app in a file named `app.py`.

```python
import logging

from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

logging.basicConfig(level=logging.DEBUG)

app = App()

# Add functionality here

if __name__ == "__main__":
SocketModeHandler(app.start(3000)).start()
```

### Application Configuration

Define your [Application Manifest](https://api.slack.com/reference/manifests) in a `manifest.json` file.

```json
{
"$schema": "https://raw.githubusercontent.com/slackapi/manifest-schema/main/manifest.schema.json",
"_metadata": {
"major_version": 1,
"minor_version": 1
},
"display_information": {
"name": "most-basic-app"
},
"outgoing_domains": [],
"settings": {
"org_deploy_enabled": true,
"socket_mode_enabled": true,
"token_rotation_enabled": false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this property required? If not, we can remove this line to make the example as simple as possible. I believe the default value for this is false.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, this line is not required 💯

},
"features": {
"bot_user": {
"display_name": "most-basic-app"
}
},
"oauth_config": {
"scopes": {
"bot": ["chat:write"]
}
}
}
```

WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved
Define the Slack CLI configuration in a file named `slack.json`.

```json
{
"hooks": {
"get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this require that the terminal session from where the CLI is executed already have the venv activated? If so, is there any way that we could have the hook activate the relevant venv automatically if it is not already active?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a great addition 💯 the issue is that the venv folder is arbitrarily named I follow the env*.*.* pattern but many name it .venv, venv, .env....

It might be easier to provide a proper error message is the hook fails?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also be done in a follow up PR

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya sounds good, just thinking through how things could fail here.
My shell (oh-my-zsh) somehow understands when a venv is activated, as it prints the name of the venv in my shell prefix (screenshot attached).

Screenshot 2023-12-07 at 8 37 11 AM

It seems like the activate script sets an environment variable:

VIRTUAL_ENV="/Users/fmaj/src/remote-func-py-mvp/.venv"
export VIRTUAL_ENV

Maybe the CLI hooks could look for the presence of this env var and if it is missing, error out but with a helpful message? Dunno, thinking out loud.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue here is that using a venv is not enforced by python, from what I know using a venv is considered good practice but a dev could chose to use no env/dependency manager or use an alternative like conda

Maybe we could detect if the module is not installed on their path and ask to install it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a lot of experience w/ building apps that use Python frameworks, but do application building frameworks in Python at all rely on venv? Like, are there frameworks that require developers to use venvs, or is that generally considered bad form in the Python community?
IMO it feels OK for us to require developers to use venv if they want to use Bolt, but that is just my opinion.
@seratch do you have opinions on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a quick research on this and there is a push for virtualenv since Python comes bundled with the venv module to create virtual environments.

All recommend using venv but do not rely on it
VS code and PyCharm support many environment managers including venv and conda

It seems like if we rely only on venv we would be providing an opinionated experience (we could potentially support venv and conda)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for that info and summary! Feel free to drop it - perhaps we can revisit in the future.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @WilliamBergamin on this > The issue here is that using a venv is not enforced by python, from what I know using a venv is considered good practice but a dev could chose to use no env/dependency manager or use an alternative like conda

Devs have freedom not to use venv for running apps (think about a Docker container with a specific Python version; just relying on the installed runtime is totally fine in the case). Great to see this discussion!

}
}
```

Create an empty folder named `.slack`
WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved

### 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
```
WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 1 addition & 2 deletions requirements/format.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
black==22.8.0; python_version=="3.6"
black; python_version>"3.6" # Until we drop Python 3.6 support, we have to stay with this version
flake8>=5.0.4; python_version=="3.6"
flake8==6.0.0; python_version>"3.6"
flake8>=5.0.4, <7;
pytype; (python_version<"3.11" or python_version>"3.11")
pytype==2023.11.29; python_version=="3.11"
3 changes: 3 additions & 0 deletions requirements/testing.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# pip install -r requirements/testing.txt
pytest>=6.2.5,<7
pytest-cov>=3,<4
Flask>=2.0.3,<4
gevent>=22.10.2,<24
gevent-websocket>=0.10.1,<1
7 changes: 7 additions & 0 deletions slack_cli_hooks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""
A Slack CLI hooks implementation in Python to build Bolt Slack apps leveraging the full power of the [Slack CLI](https://api.slack.com/automation/cli/install). Look at our [code examples](https://github.com/slackapi/python-slack-hooks/tree/main/examples) to learn how to build apps using the SLack CLI and Bolt.

* Slack CLI: https://api.slack.com/automation/cli/install
* Bolt Website: https://slack.dev/bolt-python/
* GitHub repository: https://github.com/slackapi/python-slack-hooks
""" # noqa: E501
2 changes: 2 additions & 0 deletions slack_cli_hooks/error/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class CliError(Exception):
"""General class for cli error"""
2 changes: 2 additions & 0 deletions slack_cli_hooks/hooks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Slack CLI contract implementation for Bolt.
"""
23 changes: 23 additions & 0 deletions slack_cli_hooks/hooks/get_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env python
import json
from slack_cli_hooks.protocol import Protocol, MessageBoundaryProtocol, DefaultProtocol, protocol_factory

PROTOCOL: Protocol
EXEC = "python3"


hooks_payload = {
"hooks": {
"get-manifest": f"{EXEC} -m slack_cli_hooks.hooks.get_manifest",
"start": f"{EXEC} -m slack_cli_hooks.hooks.start",
},
"config": {
"watcher": {"filter-regex": "^manifest\\.(json)$", "paths": ["."]},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably want to watch for .py changes too, right? Since Bolt will manage the websocket connection, it will operate as a single long-running process (as opposed to how the deno SDK operates, where every event starts a new deno process). Therefore, the Bolt process needs to be restarted every time app source code is changed to reflect the change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch 💯 I tried implementing this but can't seem to get it to work, I've brought it up to the wider team

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Brining it up with the wider team reviled that the Slack CLI will not restart the start hook

I've decided to leave the "filter-regex": "^manifest\\.(json)$" since support for autoreload will be added in follow up PRs

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally fair! Thanks for following up on it.

"protocol-version": [MessageBoundaryProtocol.name, DefaultProtocol.name],
WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved
"sdk-managed-connection-enabled": True,
},
}

if __name__ == "__main__":
PROTOCOL = protocol_factory()
PROTOCOL.respond(json.dumps(hooks_payload))
50 changes: 50 additions & 0 deletions slack_cli_hooks/hooks/get_manifest.py
Original file line number Diff line number Diff line change
@@ -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()))
80 changes: 80 additions & 0 deletions slack_cli_hooks/hooks/start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/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"


class EnvVarHandler:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to better understand the benefit of having this class. This class does destructive changes to real os.environ. In this case, is the state represented by self._is_set necessary? If the main benefit of this is to have the clean method, these methods could be simple static functions instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, The goal of EnvVarHandler is to allow users to set their own environment variable values and not have them over-written and deleted by this hooks implementation

If a user sets the environment variable SLACK_APP_TOKEN then the app should use it and its value should still be present after the program terminates

self._is_set is necessary since it dictates wether the environment variable was set by the user prior to the execution of the program

Let me know if this should be renamed or if there is a better implementation

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sorry I misread the code and now I understand the intention of it.

Allowing devs to go with their own env variables should be fine but we can make the underlying behavior more visible, especially when the CLI associated tokens are unused. When a dev maintains a few Slack apps, they can unintentionally have env variables like SLACK_BOT_TOKEN in a terminal shell session. How about adding logging outputs like your SLACK_BOT_TOKEN env variable is used over the CLI's tokens etc.?

Also, I'd suggest renaming the class and methods to be even clearer:

# Let's add "os" to avoid confusion with `slack env`
os_env_vars = ManagedOSEnvVars()
try:
    os_env_vars.set_if_absent(from=SLACK_CLI_XOXB, to=SLACK_BOT_TOKEN)
    os_env_vars.set_if_absent(from=SLACK_CLI_XAPP, to=SLACK_APP_TOKEN)
finally:
    os_env_vars.clear()

You can adjust the details but I hope this psuedo code illustrates my idea well.

def __init__(self, name: str) -> None:
self.name = name
self._is_set = name in os.environ

def set_default(self, value: str) -> None:
if not self._is_set:
os.environ[self.name] = value

def clean(self) -> None:
if not self._is_set:
os.environ.pop(self.name, None)


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)

bot_token_var = EnvVarHandler(SLACK_BOT_TOKEN)
app_token_var = EnvVarHandler(SLACK_APP_TOKEN)

try:
bot_token_var.set_default(os.environ[SLACK_CLI_XOXB])
app_token_var.set_default(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)
bot_token_var.clean()
app_token_var.clean()


if __name__ == "__main__":
PROTOCOL = protocol_factory()
start(os.getcwd())
24 changes: 24 additions & 0 deletions slack_cli_hooks/protocol/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved
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()
WilliamBergamin marked this conversation as resolved.
Show resolved Hide resolved
24 changes: 24 additions & 0 deletions slack_cli_hooks/protocol/default_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from .protocol import Protocol


class DefaultProtocol(Protocol):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Damn, this is clean! 💯

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)
24 changes: 24 additions & 0 deletions slack_cli_hooks/protocol/message_boundary_protocol.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👨‍🍳 💋

Loading