feat: grounds-up rewrite + leverage existing sdks + remove cruft
This commit is contained in:
parent
add064e7c9
commit
23ef43e6b2
16 changed files with 297 additions and 476 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -4,8 +4,9 @@ __pycache__/
|
||||||
*$py.class
|
*$py.class
|
||||||
*.venv
|
*.venv
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
|
||||||
|
|
||||||
|
*.so
|
||||||
|
config.yml
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
|
|
33
README.md
33
README.md
|
@ -1,10 +1,6 @@
|
||||||
# slack-status-cli
|
# slck
|
||||||
:sparkle: Tooling to set your Slack status on the fly without having to click around
|
|
||||||
|
|
||||||
[![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)
|
[![python-support](https://img.shields.io/badge/python-%5E3.12-brightgreen)]()
|
||||||
[![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
|
## 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
|
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.
|
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.
|
More importantly, you can also set presets and defaults to save time on statuses you reuse all the time.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
You can use `slack-status-cli` without a configuration file and provide everything via arguments (see `slack-status-cli
|
A configuration YAML file can be used to set up credentials and presets:
|
||||||
-h` for the list of flags you can pass in), or set up a file under `~/.config/slack-status-cli` that follows the format:
|
|
||||||
|
|
||||||
```json
|
```yaml
|
||||||
{
|
token: <slack-token>
|
||||||
"presets": {
|
presets:
|
||||||
"pairing": { "text": "Pairing", "icon": ":pear:" }
|
<preset-label>:
|
||||||
},
|
text: ...
|
||||||
"defaults": { "duration": "1h", "icon": ":calendar:" }
|
emoji: ...
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`presets` allows you to set up a map of labels (used to select the preset) to values (defining the status text, icon and
|
`presets` allows you to set up a map of labels (used to select the preset) to values (defining the status text, emoji).
|
||||||
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
|
## 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
97
main.py
Normal 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)
|
|
@ -1,29 +1,28 @@
|
||||||
[project]
|
[project]
|
||||||
name = "slack-status-cli"
|
name = "slck"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Tooling to set your Slack status on the fly without having to click around"
|
description = "Tooling to set your Slack status on the fly without having to click around"
|
||||||
requires-python = ">=3.8"
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"click~=8.0",
|
||||||
|
"pydantic~=2.0",
|
||||||
|
"slack_sdk~=3.0",
|
||||||
|
"pyyaml~=6.0"
|
||||||
|
]
|
||||||
|
requires-python = "~= 3.12"
|
||||||
|
|
||||||
[[project.authors]]
|
[[project.authors]]
|
||||||
name = "Marc Cataford"
|
name = "Marc Cataford"
|
||||||
email = "mcat@riseup.net"
|
email = "mcat@riseup.net"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/mcataford/slack-status-cli"
|
Homepage = "https://forge.karnov.club/marc/slck"
|
||||||
"Bug Tracker" = "https://github.com/mcataford/slack-status-cli/issues"
|
"Bug Tracker" = "https://forge.karnov.club/marc/slck/issues"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"build",
|
"pytest~=8.0"
|
||||||
"toml",
|
|
||||||
"pytest",
|
|
||||||
"black",
|
|
||||||
"pylint",
|
|
||||||
"pytest-cov",
|
|
||||||
"syrupy",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[project.scripts]
|
||||||
packages = [ "slack_status_cli",]
|
slck = "main:run"
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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])
|
|
|
@ -1 +0,0 @@
|
||||||
__version__ = "0.1.0"
|
|
|
@ -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
173
test.py
Normal 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!"
|
|
@ -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}",
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
# ---
|
|
|
@ -1,6 +0,0 @@
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def slack_api_token():
|
|
||||||
return "mock-slack-token"
|
|
|
@ -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
|
|
Loading…
Reference in a new issue