feat: grounds-up rewrite + leverage existing sdks + remove cruft

This commit is contained in:
Marc 2024-09-12 18:31:45 -04:00
parent add064e7c9
commit 23ef43e6b2
Signed by: marc
GPG key ID: 048E042F22B5DC79
16 changed files with 297 additions and 476 deletions

3
.gitignore vendored
View file

@ -4,8 +4,9 @@ __pycache__/
*$py.class
*.venv
# C extensions
*.so
*.so
config.yml
# Distribution / packaging
.Python
build/

View file

@ -1,10 +1,6 @@
# slack-status-cli
:sparkle: Tooling to set your Slack status on the fly without having to click around
# slck
[![CICD](https://github.com/mcataford/slack-status-cli/actions/workflows/main.yml/badge.svg)](https://github.com/mcataford/slack-status-cli/actions/workflows/main.yml)
[![codecov](https://codecov.io/gh/mcataford/slack-status-cli/branch/main/graph/badge.svg?token=10VP1ZDBHR)](https://codecov.io/gh/mcataford/slack-status-cli)
[![python-support](https://img.shields.io/badge/python-%5E3.7-brightgreen)]()
[![latest-release](https://img.shields.io/github/v/release/mcataford/slack-status-cli?include_prereleases&label=latest%20release&sort=semver)]()
[![python-support](https://img.shields.io/badge/python-%5E3.12-brightgreen)]()
## Overview
@ -12,28 +8,23 @@ Clicking around Slack to update statuses is not only annoying, but if you use st
to broadcast what you are up to when jumping into new things, you quickly find yourself spending minutes of you day
clicking around and setting the same statuses over and over again since the UI isn't great at remembering them.
Enter `slack-status-cli`. With it, you can set statuses (with or without expiration dates) without leaving the terminal.
Enter `slck`. With it, you can set statuses (with or without expiration dates) without leaving the terminal.
More importantly, you can also set presets and defaults to save time on statuses you reuse all the time.
## Configuration
You can use `slack-status-cli` without a configuration file and provide everything via arguments (see `slack-status-cli
-h` for the list of flags you can pass in), or set up a file under `~/.config/slack-status-cli` that follows the format:
A configuration YAML file can be used to set up credentials and presets:
```json
{
"presets": {
"pairing": { "text": "Pairing", "icon": ":pear:" }
},
"defaults": { "duration": "1h", "icon": ":calendar:" }
}
```yaml
token: <slack-token>
presets:
<preset-label>:
text: ...
emoji: ...
```
`presets` allows you to set up a map of labels (used to select the preset) to values (defining the status text, icon and
duration), `defaults` allows you to set up sane defaults used in all statuses if the specified fields are not provided
(in the above, all statuses would have a duration of one hour if not specified, and a default :calendar: icon -- presets
and/or CLI args will override these defaults if given).
`presets` allows you to set up a map of labels (used to select the preset) to values (defining the status text, emoji).
## Installation
You can clone this repository and build from source or use pre-built artifacts. To build yourself, you can `. script/bootstrap && python -m build`. Build artifacts for released versions are also available under [releases](https://github.com/mcataford/slack-status-cli/releases).
The best way to enjoy this is via pipx: `pipx install git+https://forge.karnov.club/marc/slck`.

97
main.py Normal file
View file

@ -0,0 +1,97 @@
"""
slck: Slack Status CLI
Facilitates setting Slack status text/emojis via the
command-line.
"""
import pathlib
import click
import pydantic
import slack_sdk
import yaml
class ProfilePayload(pydantic.BaseModel):
"""Profile payload sent to Slack's API."""
status_text: str = ""
status_emoji: str = ""
status_expiration: int = 0
class Preset(pydantic.BaseModel):
"""Represents a set of value used as a preset."""
text: str
emoji: str = ""
class Configuration(pydantic.BaseModel):
"""Tool configuration."""
token: str
presets: dict[str, Preset]
def get_client(token: str) -> slack_sdk.WebClient:
"""Returns an authenticated API client."""
return slack_sdk.WebClient(token=token)
def get_configuration(path: str) -> Configuration:
"""Loads configuration from file."""
conf_path = pathlib.Path(path)
if not conf_path.exists():
raise RuntimeError(f"Configuration file not found: {path}")
with open(conf_path, "r", encoding="utf8") as conf_file:
return Configuration(**yaml.safe_load(conf_file))
@click.command()
@click.option("--text", "-t", help="Status text.")
@click.option("--emoji", "-e", help="Emoji attached to the status.")
@click.option("--preset", "-p", help="Preset for text/emoji combinations.")
@click.option("--config", "-c", "config_path", help="Path to configuration.")
def cli(
*, text: str = None, emoji: str = None, preset: str = None, config_path: str = None
):
if text is None and preset is None:
raise RuntimeError(
"Must specify either status text via --text/-t or a preset via --preset/-p."
)
conf = get_configuration(config_path)
status_text, status_emoji = None, None
if preset is not None and preset in conf.presets:
preset_data = conf.presets[preset]
status_text = preset_data.text if preset_data.text else status_text
status_emoji = preset_data.emoji if preset_data.emoji else status_emoji
elif preset is not None:
raise RuntimeError(f"Unknown preset: {preset}")
if text is not None:
status_text = text
if emoji is not None:
status_emoji = emoji
payload = ProfilePayload(status_text=status_text, status_emoji=status_emoji)
client = get_client(conf.token)
api_response = client.users_profile_set(profile=payload.model_dump())
if not api_response.get("ok", False):
raise RuntimeError("Failed to set status!")
def run():
"""Entrypoint."""
try:
cli()
except Exception as e:
click.echo(e)

View file

@ -1,29 +1,28 @@
[project]
name = "slack-status-cli"
name = "slck"
version = "0.1.0"
description = "Tooling to set your Slack status on the fly without having to click around"
requires-python = ">=3.8"
readme = "README.md"
dependencies = []
dependencies = [
"click~=8.0",
"pydantic~=2.0",
"slack_sdk~=3.0",
"pyyaml~=6.0"
]
requires-python = "~= 3.12"
[[project.authors]]
name = "Marc Cataford"
email = "mcat@riseup.net"
[project.urls]
Homepage = "https://github.com/mcataford/slack-status-cli"
"Bug Tracker" = "https://github.com/mcataford/slack-status-cli/issues"
Homepage = "https://forge.karnov.club/marc/slck"
"Bug Tracker" = "https://forge.karnov.club/marc/slck/issues"
[project.optional-dependencies]
dev = [
"build",
"toml",
"pytest",
"black",
"pylint",
"pytest-cov",
"syrupy",
"pytest~=8.0"
]
[tool.setuptools]
packages = [ "slack_status_cli",]
[project.scripts]
slck = "main:run"

View file

@ -1 +0,0 @@

View file

@ -1,50 +0,0 @@
astroid==3.0.3
# via pylint
black==24.1.1
# via slack-status-cli (pyproject.toml)
build==1.0.3
# via slack-status-cli (pyproject.toml)
click==8.1.7
# via black
coverage[toml]==7.4.1
# via pytest-cov
dill==0.3.8
# via pylint
iniconfig==2.0.0
# via pytest
isort==5.13.2
# via pylint
mccabe==0.7.0
# via pylint
mypy-extensions==1.0.0
# via black
packaging==23.2
# via
# black
# build
# pytest
pathspec==0.12.1
# via black
platformdirs==4.2.0
# via
# black
# pylint
pluggy==1.4.0
# via pytest
pylint==3.0.3
# via slack-status-cli (pyproject.toml)
pyproject-hooks==1.0.0
# via build
pytest==7.4.4
# via
# pytest-cov
# slack-status-cli (pyproject.toml)
# syrupy
pytest-cov==4.1.0
# via slack-status-cli (pyproject.toml)
syrupy==4.6.0
# via slack-status-cli (pyproject.toml)
toml==0.10.2
# via slack-status-cli (pyproject.toml)
tomlkit==0.12.3
# via pylint

View file

@ -1,14 +0,0 @@
#!/bin/bash
PROJECT="slack-status-cli"
python -m pip install --upgrade setuptools
python -m pip install pip~=23.0 pip-tools~=7.3 --no-cache
if [ ! -d "./$PROJECT.venv" ]; then
python -m venv ./$PROJECT.venv
fi
source ./$PROJECT.venv/bin/activate
pip-sync ./requirements.txt ./requirements_dev.txt

View file

@ -1,25 +0,0 @@
#!/bin/bash
PROD_DEP="requirements.txt"
DEV_DEP="requirements_dev.txt"
echo "Locking production dependencies as $PROD_DEP"
python -m piptools compile \
-o $PROD_DEP \
--no-header \
pyproject.toml
if [[ $? != 0 ]]; then
echo "Failed to lock production dependencies."
return 1
fi
echo "Locking production dependencies as $DEV_DEP"
python -m piptools compile \
-o $DEV_DEP \
--no-header \
--extra dev \
--constraint $PROD_DEP \
pyproject.toml

View file

@ -1,21 +0,0 @@
"""
Fixes the version of pyproject.toml, for use before the package
artifacts are built.
"""
import sys
import toml
def set_version(new_version: str):
with open("./pyproject.toml", "r", encoding="utf8") as pyproject:
project_config = toml.loads(pyproject.read())
project_config["project"]["version"] = new_version
print("Bumped version to %s" % new_version)
with open("./pyproject.toml", "w", encoding="utf8") as pyproject:
toml.dump(project_config, pyproject)
set_version(sys.argv[1])

View file

@ -1 +0,0 @@
__version__ = "0.1.0"

View file

@ -1,255 +0,0 @@
"""
CLI Slack Status Handling
Provides a shortcut to set Slack statuses from the command-line. Since statuses are often
canned, this tool also facilitates setting up presets that can be quickly invoked.
With custom text:
SLACK_TOKEN=XXX slack-status-cli set --text <text> --icon <icon> --duration <duration_description>
With preset:
SLACK_TOKEN=XXX slack-status-cli set --preset <preset-name> --duration <duration_description>
"""
import urllib.request
import urllib.parse
import typing
import os
import logging
import argparse
import datetime
import collections
import re
import json
import pathlib
import sys
# Debug mode modifies the log level used for reporting. If truthy,
# extra information is included in each run to diagnose common
# issues.
DEBUG = bool(os.environ.get("DEBUG", False))
# Logger setup
logging.basicConfig()
logger = logging.getLogger(__name__)
logger.propagate = False
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
log_handler = logging.StreamHandler()
log_handler.setLevel(level=logging.DEBUG if DEBUG else logging.INFO)
log_handler.setFormatter(logging.Formatter(fmt="%(message)s"))
logger.addHandler(log_handler)
ParsedUserInput = collections.namedtuple(
"ParsedUserInput", ["text", "icon", "duration", "preset"]
)
StatusPreset = collections.namedtuple("StatusPreset", ["text", "icon"])
Defaults = collections.namedtuple(
"Defaults",
[("icon"), ("duration")],
defaults=[None, None],
)
Configuration = collections.namedtuple(
"Configuration",
[
("presets"),
("defaults"),
],
defaults=[{}, Defaults()],
)
def update_status(
token: str,
status: str,
emoticon: typing.Optional[str] = None,
expiration: typing.Optional[int] = None,
):
"""
Sets the Slack status of the given user to <status>, optionally with <emoticon> if provided.
If an expiration is provided, the status is set to expire after this time.
"""
payload = {
"profile": {
"status_text": status,
"status_emoji": emoticon or "",
"status_expiration": expiration or 0,
}
}
headers = {
"Authorization": f"Bearer {token}",
}
request = urllib.request.Request(
"https://slack.com/api/users.profile.set",
urllib.parse.urlencode(payload).encode(),
method="POST",
)
for header_key, header_value in headers.items():
request.add_header(header_key, header_value)
response = urllib.request.urlopen(request)
response_status = response.status
response_data = response.read()
logger.debug("API request: %s", str(payload))
logger.debug("API response: %s", str(response_data))
if response_status != 200:
raise Exception("Failed to set status due to an API error.")
response_data = json.loads(response_data)
if not response_data["ok"]:
raise Exception("Failed to set status due to an API error.")
def parse_input(known_presets: typing.List[str]) -> ParsedUserInput:
"""
Handles command-line argument parsing and help text display.
"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
set_parser = subparsers.add_parser("set", help="Set your Slack status")
set_parser.add_argument("--text", type=str, help="Status text")
set_parser.add_argument(
"--icon",
type=str,
default=None,
help="Status icon (as defined by your workspace) in :icon: format",
)
set_parser.add_argument(
"--duration",
type=str,
default=None,
help="Status duration, formatted as AdBhCm (each segment is optional)",
)
set_parser.add_argument(
"--preset", type=str, default=None, choices=known_presets, help="Preset to use"
)
args = parser.parse_args()
return ParsedUserInput(
text=args.text, icon=args.icon, duration=args.duration, preset=args.preset
)
def get_expiration(duration_description: typing.Optional[str] = None) -> int:
"""
Gets an expiration timestamp based on a duration description string of the
format <int>d<int>h<int>m.
"""
if not duration_description:
return 0
DURATION_PATTERN = (
r"^((?P<days>[0-9]+)d)?((?P<hours>[0-9]+)h)?((?P<minutes>[0-9]+)m)?$"
)
duration_input = re.match(DURATION_PATTERN, duration_description)
duration_parts = duration_input.groupdict()
duration = datetime.timedelta(
days=int(duration_parts.get("days") or 0),
minutes=int(duration_parts.get("minutes") or 0),
hours=int(duration_parts.get("hours") or 0),
)
return (datetime.datetime.now() + duration).timestamp()
def load_configuration() -> Configuration:
"""
Loads from configuration file if present.
"""
configuration_path = pathlib.Path.home().joinpath(".config", "slack-status-cli")
if not configuration_path.exists():
return Configuration()
with open(configuration_path, "r", encoding="utf-8") as config_file:
config = config_file.read()
try:
parsed_config = json.loads(config)
logger.debug("Loaded configuration: %s", parsed_config)
preset_config = parsed_config.get("presets", {})
defaults_config = parsed_config.get("defaults", {})
presets = {
preset_key: StatusPreset(
text=preset_value["text"], icon=preset_value.get("icon")
)
for preset_key, preset_value in preset_config.items()
}
defaults = Defaults(
icon=defaults_config.get("icon"), duration=defaults_config.get("duration")
)
return Configuration(presets=presets, defaults=defaults)
except Exception:
logger.warning("Invalid configuration found at %s", str(configuration_path))
return Configuration()
def run():
try:
configuration = load_configuration()
args = parse_input(configuration.presets.keys())
if args.preset and not args.preset in configuration.presets:
raise Exception("Unknown preset %s" % args.preset)
token = os.environ.get("SLACK_TOKEN")
if not token:
raise Exception("Slack token not provided.")
status_text = args.text
status_icon = args.icon
status_expiration = get_expiration(
args.duration or configuration.defaults.duration
)
if args.preset:
preset = configuration.presets[args.preset]
status_text = preset.text
status_icon = preset.icon
update_status(
token,
status_text,
status_icon or configuration.defaults.icon,
status_expiration,
)
new_status = (
"%s %s" % (status_icon, status_text) if status_icon else status_text
)
new_expiry = (
"(expires %s)"
% datetime.datetime.fromtimestamp(status_expiration).strftime(
"%A, %B %d, %H:%M"
)
if status_expiration
else "(no expiration)"
)
logger.info("✨ Status set to '%s' %s", new_status, new_expiry)
except Exception as e:
logger.error("🔥 Could not set status: %s", str(e))
sys.exit(1)
if __name__ == "__main__":
run()

173
test.py Normal file
View file

@ -0,0 +1,173 @@
from unittest.mock import Mock, patch
import pytest
from click.testing import CliRunner
from main import cli, get_configuration
@pytest.fixture(name="with_sample_config")
def fixture_with_sample_config(tmp_path):
config_path = tmp_path / "config.yml"
config_path.write_text(
"""
token: abc
presets:
test:
text: abc
emoji: ":tada:"
"""
)
def test_get_configuration_raises_if_noexist():
with pytest.raises(RuntimeError):
get_configuration("not/a/path.yml")
def test_get_configuration_returns_configuration_obj(tmp_path):
config_path = tmp_path / "config.yml"
config_path.write_text(
"""
token: abc
presets:
test:
text: abc
emoji: ":tada:"
"""
)
conf = get_configuration(config_path)
assert conf.token == "abc"
assert "test" in conf.presets
assert conf.presets["test"].text == "abc"
assert conf.presets["test"].emoji == ":tada:"
def test_cli_requires_text_or_preset_input(tmp_path, with_sample_config):
runner = CliRunner()
result = runner.invoke(cli, ["--config", tmp_path / "config.yml"])
assert isinstance(result.exception, RuntimeError)
assert (
str(result.exception)
== "Must specify either status text via --text/-t or a preset via --preset/-p."
)
def test_cli_overrides_preset_with_text_input(with_sample_config, tmp_path):
runner = CliRunner()
mock_client = Mock()
mock_client.users_profile_set = Mock()
with patch(
"main.get_client", autospec=True, return_value=mock_client
):
result = runner.invoke(
cli,
[
"--text",
"testtext",
"--preset",
"test",
"--config",
tmp_path / "config.yml",
],
)
call_args = mock_client.users_profile_set.call_args
assert result.exit_code == 0
assert call_args.kwargs["profile"]["status_text"] == "testtext"
assert call_args.kwargs["profile"]["status_emoji"] == ":tada:"
def test_cli_overrides_preset_with_emoji_input(with_sample_config, tmp_path):
runner = CliRunner()
mock_client = Mock()
mock_client.users_profile_set = Mock()
with patch(
"main.get_client", autospec=True, return_value=mock_client
):
result = runner.invoke(
cli,
[
"--emoji",
":skull:",
"--preset",
"test",
"--config",
tmp_path / "config.yml",
],
)
call_args = mock_client.users_profile_set.call_args
assert result.exit_code == 0
assert call_args.kwargs["profile"]["status_text"] == "abc"
assert call_args.kwargs["profile"]["status_emoji"] == ":skull:"
def test_cli_raises_if_noexist_preset(tmp_path, with_sample_config):
runner = CliRunner()
mock_client = Mock()
mock_client.users_profile_set = Mock()
with patch(
"main.get_client", autospec=True, return_value=mock_client
):
result = runner.invoke(
cli, ["--preset", "not-a-preset", "--config", tmp_path / "config.yml"]
)
assert result.exit_code == 1
assert str(result.exception) == "Unknown preset: not-a-preset"
def test_cli_sends_request_to_slack(tmp_path, with_sample_config):
runner = CliRunner()
mock_client = Mock()
mock_client.users_profile_set = Mock()
with patch(
"main.get_client", autospec=True, return_value=mock_client
):
runner.invoke(
cli,
[
"--emoji",
":skull:",
"--preset",
"test",
"--config",
tmp_path / "config.yml",
],
)
mock_client.users_profile_set.assert_called()
def test_cli_raises_if_api_error(tmp_path, with_sample_config):
runner = CliRunner()
mock_client = Mock()
mock_client.users_profile_set = Mock(return_value={"ok": False})
with patch(
"main.get_client", autospec=True, return_value=mock_client
):
result = runner.invoke(
cli,
[
"--emoji",
":skull:",
"--preset",
"test",
"--config",
tmp_path / "config.yml",
],
)
assert result.exit_code == 1
assert str(result.exception) == "Failed to set status!"

View file

View file

@ -1,8 +0,0 @@
# serializer version: 1
# name: test_sends_request_to_slack_api_on_success
dict({
'profile': list([
"{'status_text': 'test', 'status_emoji': '', 'status_expiration': 0}",
]),
})
# ---

View file

@ -1,6 +0,0 @@
import pytest
@pytest.fixture
def slack_api_token():
return "mock-slack-token"

View file

@ -1,59 +0,0 @@
import unittest.mock
import urllib.parse
import os
import sys
import typing
import slack_status_cli.main
class MockResponse(typing.NamedTuple):
"""
Stand-in for http.client.HTTPResponse.
"""
status: int
response_text: str
def read(self):
return self.response_text
def test_errors_if_no_slack_token_provided(monkeypatch):
monkeypatch.setattr(sys, "argv", ["slack-status-cli", "set", "--text", "test"])
with unittest.mock.patch("sys.exit", autospec=True) as mock_exit:
slack_status_cli.main.run()
mock_exit.assert_called_with(1)
def test_sends_request_to_slack_api_on_success(
slack_api_token, monkeypatch, snapshot, tmp_path
):
env = os.environ.copy()
env["SLACK_TOKEN"] = slack_api_token
mock_response = MockResponse(status=200, response_text='{ "ok": true }')
monkeypatch.setenv("SLACK_TOKEN", slack_api_token)
monkeypatch.setattr(sys, "argv", ["slack-status-cli", "set", "--text", "test"])
with unittest.mock.patch(
"urllib.request.urlopen",
autospec=True,
return_value=mock_response,
) as mock_request, unittest.mock.patch(
"pathlib.Path.home", autospec=True, return_value=tmp_path
):
slack_status_cli.main.run()
request = mock_request.call_args_list[0][0][0]
assert request.get_full_url() == "https://slack.com/api/users.profile.set"
assert request.get_method() == "POST"
assert request.get_header("Authorization") == "Bearer %s" % slack_api_token
request_body = urllib.parse.parse_qs(request.data.decode())
assert request_body == snapshot