diff --git a/.gitignore b/.gitignore index 9b171ac..e9e0973 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ __pycache__/ *$py.class *.venv # C extensions -*.so +*.so +config.yml # Distribution / packaging .Python build/ diff --git a/README.md b/README.md index a803d8e..73e580c 100644 --- a/README.md +++ b/README.md @@ -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: +presets: + : + 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`. diff --git a/main.py b/main.py new file mode 100644 index 0000000..31856d3 --- /dev/null +++ b/main.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 44af02f..29685ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8b13789..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 315ebe2..0000000 --- a/requirements_dev.txt +++ /dev/null @@ -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 diff --git a/script/bootstrap.sh b/script/bootstrap.sh deleted file mode 100644 index 1e58cf1..0000000 --- a/script/bootstrap.sh +++ /dev/null @@ -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 diff --git a/script/lock.sh b/script/lock.sh deleted file mode 100644 index ffdeda3..0000000 --- a/script/lock.sh +++ /dev/null @@ -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 diff --git a/set_version.py b/set_version.py deleted file mode 100644 index 583bfaa..0000000 --- a/set_version.py +++ /dev/null @@ -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]) diff --git a/slack_status_cli/__init__.py b/slack_status_cli/__init__.py deleted file mode 100644 index 3dc1f76..0000000 --- a/slack_status_cli/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/slack_status_cli/main.py b/slack_status_cli/main.py deleted file mode 100644 index efdba68..0000000 --- a/slack_status_cli/main.py +++ /dev/null @@ -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 --icon --duration - -With preset: - -SLACK_TOKEN=XXX slack-status-cli set --preset --duration -""" - -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 , optionally with 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 dhm. - """ - - if not duration_description: - return 0 - - DURATION_PATTERN = ( - r"^((?P[0-9]+)d)?((?P[0-9]+)h)?((?P[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() diff --git a/test.py b/test.py new file mode 100644 index 0000000..0c69317 --- /dev/null +++ b/test.py @@ -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!" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/__snapshots__/test_integration.ambr b/tests/__snapshots__/test_integration.ambr deleted file mode 100644 index 1e1b29e..0000000 --- a/tests/__snapshots__/test_integration.ambr +++ /dev/null @@ -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}", - ]), - }) -# --- diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 3c51f5d..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -import pytest - - -@pytest.fixture -def slack_api_token(): - return "mock-slack-token" diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 38e0312..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -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