Compare commits

...

9 commits

22 changed files with 395 additions and 648 deletions

View file

@ -1,118 +0,0 @@
name: CICD
on:
push:
branches: main
pull_request:
jobs:
setup:
runs-on: ubuntu-latest
strategy:
matrix:
python: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
- uses: actions/cache@v2
id: dep-cache
with:
key: ${{ runner.os }}-py${{ matrix.python }}-${{ hashFiles('requirements_dev.txt') }}
path: |
./slack-status-cli.venv
- name: Setup dependencies
if: steps.dep-cache.outputs.cache-hit != 'true'
run: |
. script/bootstrap
test:
runs-on: ubuntu-latest
needs: setup
strategy:
matrix:
python: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
- uses: actions/cache@v2
id: dep-cache
with:
key: ${{ runner.os }}-py${{ matrix.python }}-${{ hashFiles('requirements_dev.txt') }}
path: |
./slack-status-cli.venv
- name: Setup dependencies
run: |
. script/bootstrap
- name: Tests
run: |
python -m pytest --cov-report xml --cov=slack_status_cli -s
- uses: actions/upload-artifact@v2
with:
name: coverage-report
path: ./coverage.xml
coverage:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v2
- uses: actions/download-artifact@v2
with:
name: coverage-report
path: ./
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: true
verbose: true
lint:
runs-on: ubuntu-latest
needs: setup
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.9
- uses: actions/cache@v2
id: dep-cache
with:
key: ${{ runner.os }}-py${{ matrix.python }}-${{ hashFiles('requirements_dev.txt') }}
path: |
./slack-status-cli.venv
- name: Setup dependencies
run: |
. script/bootstrap
- name: Lint and format
run: |
python -m pylint **/*.py
python -m black . --check
build:
runs-on: ubuntu-latest
needs: setup
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.9
- uses: actions/cache@v2
id: dep-cache
with:
key: ${{ runner.os }}-py${{ matrix.python }}-${{ hashFiles('requirements_dev.txt') }}
path: |
./slack-status-cli.venv
- name: Setup dependencies
run: |
. script/bootstrap
- name: Lint and format
run: |
python -m build
- uses: actions/upload-artifact@v2
with:
name: current-build
path: dist/*

View file

@ -1,32 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Get tag
id: get-tag
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/v}
- name: Prepare
run: |
. script/bootstrap
python ./set_version.py ${{ steps.get-tag.outputs.tag }}
python -m build
- uses: softprops/action-gh-release@v1
with:
tag_name: ${{ join(['v', steps.get-tag.outputs.tag]) }}
draft: true
files: ./dist/*

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,7 +0,0 @@
[MESSAGES CONTROL]
disable=consider-using-f-string,
broad-except,
invalid-name,
missing-module-docstring,
missing-function-docstring,
consider-using-with

View file

@ -1 +0,0 @@
3.9.6

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,27 @@ 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.git
```

133
main.py Normal file
View file

@ -0,0 +1,133 @@
"""
slck: Slack Status CLI
Facilitates setting Slack status text/emojis via the
command-line.
"""
import datetime
import pathlib
import re
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 | None = ""
status_expiration: int | None = 0
class Preset(pydantic.BaseModel):
"""Represents a set of value used as a preset."""
text: str
emoji: str | None = ""
duration: str | None = ""
class Configuration(pydantic.BaseModel):
"""Tool configuration."""
token: str
presets: dict[str, Preset] | None
def get_client(token: str) -> slack_sdk.WebClient:
"""Returns an authenticated API client."""
return slack_sdk.WebClient(token=token)
def parse_duration(duration: str) -> int:
"""Parses duration descriptors of the form xdyhzm (x,y,z integers) into timestamps relative to present."""
duration_pattern = re.compile(r"(?P<days>\d+d)?(?P<hours>\d+h)?(?P<minutes>\d+m)?")
matches = duration_pattern.search(duration)
delta = datetime.timedelta()
if matches.group("days"):
delta += datetime.timedelta(days=int(matches.group("days").rstrip("d")))
if matches.group("hours"):
delta += datetime.timedelta(hours=int(matches.group("hours").rstrip("h")))
if matches.group("minutes"):
delta += datetime.timedelta(minutes=int(matches.group("minutes").rstrip("m")))
return (datetime.datetime.now() + delta).timestamp()
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("--duration", "-d", help="Duration of 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,
duration: 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, status_exp = None, None, 0
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
status_exp = (
parse_duration(preset_data.duration) if preset_data.duration else status_exp
)
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
if duration is not None:
status_exp = parse_duration(duration)
payload = ProfilePayload(
status_text=status_text,
status_emoji=status_emoji,
status_expiration=int(status_exp),
)
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,16 +1,38 @@
[project]
name = "slack-status-cli"
version = "0.1.0"
name = "slck"
version = "0.2.0"
description = "Tooling to set your Slack status on the fly without having to click around"
requires-python = ">=3.7"
readme = "README.md"
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"
[tool.setuptools]
packages = [ "slack_status_cli",]
[project.optional-dependencies]
dev = [
"pytest~=8.0",
"freezegun",
]
[project.scripts]
slck = "main:run"
[tool.pylint.main]
disable = [
"line-too-long",
"too-few-public-methods",
"missing-function-docstring",
"missing-module-docstring",
"broad-exception-caught"
]

View file

View file

@ -1,6 +0,0 @@
#
# This file is autogenerated by pip-compile with python 3.9
# To update, run:
#
# pip-compile ./requirements.in
#

View file

@ -1,9 +0,0 @@
-c requirements.txt
build
toml
pytest
black
pylint
pytest-cov
syrupy

View file

@ -1,80 +0,0 @@
#
# This file is autogenerated by pip-compile with python 3.9
# To update, run:
#
# pip-compile ./requirements_dev.in
#
astroid==2.12.12
# via pylint
attrs==22.1.0
# via pytest
black==22.10.0
# via -r ./requirements_dev.in
build==0.9.0
# via -r ./requirements_dev.in
click==8.1.3
# via black
colored==1.4.3
# via syrupy
coverage[toml]==6.5.0
# via pytest-cov
dill==0.3.6
# via pylint
exceptiongroup==1.0.0
# via pytest
iniconfig==1.1.1
# via pytest
isort==5.10.1
# via pylint
lazy-object-proxy==1.8.0
# via astroid
mccabe==0.7.0
# via pylint
mypy-extensions==0.4.3
# via black
packaging==21.3
# via
# build
# pytest
pathspec==0.10.1
# via black
pep517==0.13.0
# via build
platformdirs==2.5.2
# via
# black
# pylint
pluggy==1.0.0
# via pytest
pylint==2.15.5
# via -r ./requirements_dev.in
pyparsing==3.0.9
# via packaging
pytest==7.2.0
# via
# -r ./requirements_dev.in
# pytest-cov
# syrupy
pytest-cov==4.0.0
# via -r ./requirements_dev.in
syrupy==3.0.2
# via -r ./requirements_dev.in
toml==0.10.2
# via -r ./requirements_dev.in
tomli==2.0.1
# via
# black
# build
# coverage
# pep517
# pylint
# pytest
tomlkit==0.11.6
# via pylint
typing-extensions==4.4.0
# via
# astroid
# black
# pylint
wrapt==1.14.1
# via astroid

View file

@ -1,13 +0,0 @@
#!/bin/bash
PROJECT="slack-status-cli"
python -m pip install pip==21.2.0 pip-tools==6.5.0 --no-cache
if [ ! -d "./$PROJECT.venv" ]; then
python -m venv ./$PROJECT.venv
fi
source ./$PROJECT.venv/bin/activate
pip-sync ./requirements_dev.txt

View file

@ -1,4 +0,0 @@
#!/bin/bash
pip-compile ./requirements.in
pip-compile ./requirements_dev.in

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()

215
test.py Normal file
View file

@ -0,0 +1,215 @@
import datetime
from unittest.mock import Mock, patch
import freezegun
import pytest
from click.testing import CliRunner
from main import cli, get_configuration, parse_duration
@pytest.fixture(name="with_sample_config", autouse=True)
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:"
duration: "1d"
"""
)
@freezegun.freeze_time("2012-01-01")
@pytest.mark.parametrize(
["duration", "delta"],
[
["1d", datetime.timedelta(days=1)],
["1h", datetime.timedelta(hours=1)],
["1m", datetime.timedelta(minutes=1)],
["1d1m", datetime.timedelta(days=1, minutes=1)],
["1d1h", datetime.timedelta(days=1, hours=1)],
["1d1h1m", datetime.timedelta(days=1, hours=1, minutes=1)],
],
)
def test_get_parse_duration(duration, delta):
expected_time = (datetime.datetime.now() + delta).timestamp()
actual_time = parse_duration(duration)
assert expected_time == actual_time
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):
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(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(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:"
@freezegun.freeze_time("2012-01-01")
def test_cli_overrides_preset_with_exp_input(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,
[
"--duration",
"1h",
"--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"] == ":tada:"
assert (
call_args.kwargs["profile"]["status_expiration"]
== (datetime.datetime.now() + datetime.timedelta(hours=1)).timestamp()
)
def test_cli_raises_if_noexist_preset(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, ["--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):
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):
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,7 +0,0 @@
# 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