Compare commits

..

5 commits

24 changed files with 712 additions and 395 deletions

118
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,118 @@
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/*

32
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,32 @@
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,9 +4,8 @@ __pycache__/
*$py.class
*.venv
# C extensions
*.so
config.yml
# Distribution / packaging
.Python
build/

7
.pylintrc Normal file
View file

@ -0,0 +1,7 @@
[MESSAGES CONTROL]
disable=consider-using-f-string,
broad-except,
invalid-name,
missing-module-docstring,
missing-function-docstring,
consider-using-with

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.9.6

View file

@ -1,6 +1,10 @@
# slck
# slack-status-cli
:sparkle: Tooling to set your Slack status on the fly without having to click around
[![python-support](https://img.shields.io/badge/python-%5E3.12-brightgreen)]()
[![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)]()
## Overview
@ -8,27 +12,28 @@ 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 `slck`. With it, you can set statuses (with or without expiration dates) without leaving the terminal.
Enter `slack-status-cli`. 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
A configuration YAML file can be used to set up credentials and presets:
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:
```yaml
token: <slack-token>
presets:
<preset-label>:
text: ...
emoji: ...
```json
{
"presets": {
"pairing": { "text": "Pairing", "icon": ":pear:" }
},
"defaults": { "duration": "1h", "icon": ":calendar:" }
}
```
`presets` allows you to set up a map of labels (used to select the preset) to values (defining the status 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).
## Installation
The best way to enjoy this is via pipx:
```
pipx install git+https://forge.karnov.club/marc/slck.git
```
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).

133
main.py
View file

@ -1,133 +0,0 @@
"""
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,38 +1,16 @@
[project]
name = "slck"
version = "0.2.0"
name = "slack-status-cli"
version = "0.1.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://forge.karnov.club/marc/slck"
"Bug Tracker" = "https://forge.karnov.club/marc/slck/issues"
Homepage = "https://github.com/mcataford/slack-status-cli"
"Bug Tracker" = "https://github.com/mcataford/slack-status-cli/issues"
[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"
]
[tool.setuptools]
packages = [ "slack_status_cli",]

0
requirements.in Normal file
View file

6
requirements.txt Normal file
View file

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

9
requirements_dev.in Normal file
View file

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

80
requirements_dev.txt Normal file
View file

@ -0,0 +1,80 @@
#
# 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

13
script/bootstrap Normal file
View file

@ -0,0 +1,13 @@
#!/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

4
script/lock Normal file
View file

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

21
set_version.py Normal file
View file

@ -0,0 +1,21 @@
"""
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

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

View file

@ -0,0 +1,80 @@
import urllib.request
import urllib.parse
import typing
import json
import logger as log
logger = log.get_logger(__name__)
class SlackClient:
"""
Lightweight abstraction around Slack's REST API.
"""
_token: str
def __init__(self, token: str):
self._token = token
@property
def headers(self):
return {
"Authorization": f"Bearer {self._token}",
}
def _post(self, url: str, payload):
request = urllib.request.Request(
url,
urllib.parse.urlencode(payload).encode(),
method="POST",
headers=self.headers,
)
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 due to an API error.")
response_data = json.loads(response_data)
if not response_data["ok"]:
raise Exception("Failed due to an API error.")
def update_status(
self,
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.
Reference: https://api.slack.com/methods/users.profile.set
"""
payload = {
"profile": {
"status_text": status,
"status_emoji": emoticon or "",
"status_expiration": expiration or 0,
}
}
self._post("https://slack.com/api/users.profile.set", payload)
def set_do_not_disturb(self, duration_minutes: int):
"""
Silences notifications, potentially with the specified duration.
Reference: https://api.slack.com/methods/dnd.setSnooze
"""
payload = {"num_minutes": duration_minutes}
self._post("https://slack.com/api/dnd.setSnooze", payload)

View file

@ -0,0 +1,20 @@
import logging
import os
DEBUG = bool(os.environ.get("DEBUG", False))
def get_logger(name: str) -> logging.Logger:
"""
Prepares a standardized logger with the given name.
"""
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)
return logger

219
slack_status_cli/main.py Normal file
View file

@ -0,0 +1,219 @@
"""
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 typing
import os
import argparse
import datetime
import collections
import re
import json
import pathlib
import sys
import math
import client as slack_client
import logger as log
logger = log.get_logger(__name__)
ParsedUserInput = collections.namedtuple(
"ParsedUserInput", ["text", "icon", "duration", "preset", "quiet"]
)
StatusPreset = collections.namedtuple("StatusPreset", ["text", "icon", "quiet"])
Defaults = collections.namedtuple(
"Defaults",
[("icon"), ("duration")],
defaults=[None, None],
)
Configuration = collections.namedtuple(
"Configuration",
[
("presets"),
("defaults"),
],
defaults=[{}, Defaults()],
)
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"
)
set_parser.add_argument(
"--quiet", type=bool, default=False, help="Silences notifications"
)
args = parser.parse_args()
return ParsedUserInput(
text=args.text,
icon=args.icon,
duration=args.duration,
preset=args.preset,
quiet=args.quiet,
)
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.")
client = slack_client.SlackClient(token=token)
status_text = args.text
status_icon = args.icon
status_expiration = get_expiration(
args.duration or configuration.defaults.duration
)
quiet = args.quiet
if args.preset:
preset = configuration.presets[args.preset]
status_text = preset.text
status_icon = preset.icon
client.update_status(
status_text,
status_icon or configuration.defaults.icon,
status_expiration,
)
if quiet:
quiet_duration = math.ceil(
(
datetime.datetime.fromtimestamp(status_expiration)
- datetime.datetime.now()
).seconds
/ 60
)
client.set_do_not_disturb(quiet_duration)
else:
client.set_do_not_disturb(0)
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
View file

@ -1,215 +0,0 @@
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!"

0
tests/__init__.py Normal file
View file

View file

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

6
tests/conftest.py Normal file
View file

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

59
tests/test_integration.py Normal file
View file

@ -0,0 +1,59 @@
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