Compare commits
5 commits
main
...
feat/quiet
Author | SHA1 | Date | |
---|---|---|---|
2e49e77c1b | |||
706d3a364f | |||
c6abb508a3 | |||
662d5fa00a | |||
494e28d820 |
24 changed files with 712 additions and 395 deletions
118
.github/workflows/main.yml
vendored
Normal file
118
.github/workflows/main.yml
vendored
Normal 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
32
.github/workflows/release.yml
vendored
Normal 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
3
.gitignore
vendored
|
@ -4,9 +4,8 @@ __pycache__/
|
|||
*$py.class
|
||||
*.venv
|
||||
# C extensions
|
||||
|
||||
*.so
|
||||
config.yml
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
|
|
7
.pylintrc
Normal file
7
.pylintrc
Normal 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
1
.python-version
Normal file
|
@ -0,0 +1 @@
|
|||
3.9.6
|
37
README.md
37
README.md
|
@ -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
133
main.py
|
@ -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)
|
|
@ -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
0
requirements.in
Normal file
6
requirements.txt
Normal file
6
requirements.txt
Normal 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
9
requirements_dev.in
Normal file
|
@ -0,0 +1,9 @@
|
|||
-c requirements.txt
|
||||
|
||||
build
|
||||
toml
|
||||
pytest
|
||||
black
|
||||
pylint
|
||||
pytest-cov
|
||||
syrupy
|
80
requirements_dev.txt
Normal file
80
requirements_dev.txt
Normal 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
13
script/bootstrap
Normal 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
4
script/lock
Normal file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
|
||||
pip-compile ./requirements.in
|
||||
pip-compile ./requirements_dev.in
|
21
set_version.py
Normal file
21
set_version.py
Normal 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])
|
1
slack_status_cli/__init__.py
Normal file
1
slack_status_cli/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = "0.1.0"
|
80
slack_status_cli/client.py
Normal file
80
slack_status_cli/client.py
Normal 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)
|
20
slack_status_cli/logger.py
Normal file
20
slack_status_cli/logger.py
Normal 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
219
slack_status_cli/main.py
Normal 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
215
test.py
|
@ -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
0
tests/__init__.py
Normal file
7
tests/__snapshots__/test_integration.ambr
Normal file
7
tests/__snapshots__/test_integration.ambr
Normal 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
6
tests/conftest.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_api_token():
|
||||
return "mock-slack-token"
|
59
tests/test_integration.py
Normal file
59
tests/test_integration.py
Normal 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
|
Loading…
Reference in a new issue