feat: add status expiration support

This commit is contained in:
Marc 2024-09-15 15:46:45 -04:00
parent f80bc52622
commit c43a99045a
Signed by: marc
GPG key ID: 048E042F22B5DC79
3 changed files with 104 additions and 25 deletions

50
main.py
View file

@ -5,7 +5,9 @@ Facilitates setting Slack status text/emojis via the
command-line. command-line.
""" """
import datetime
import pathlib import pathlib
import re
import click import click
import pydantic import pydantic
@ -17,22 +19,23 @@ class ProfilePayload(pydantic.BaseModel):
"""Profile payload sent to Slack's API.""" """Profile payload sent to Slack's API."""
status_text: str = "" status_text: str = ""
status_emoji: str = "" status_emoji: str | None = ""
status_expiration: int = 0 status_expiration: int | None = 0
class Preset(pydantic.BaseModel): class Preset(pydantic.BaseModel):
"""Represents a set of value used as a preset.""" """Represents a set of value used as a preset."""
text: str text: str
emoji: str = "" emoji: str | None = ""
duration: str | None = ""
class Configuration(pydantic.BaseModel): class Configuration(pydantic.BaseModel):
"""Tool configuration.""" """Tool configuration."""
token: str token: str
presets: dict[str, Preset] presets: dict[str, Preset] | None
def get_client(token: str) -> slack_sdk.WebClient: def get_client(token: str) -> slack_sdk.WebClient:
@ -40,6 +43,23 @@ def get_client(token: str) -> slack_sdk.WebClient:
return slack_sdk.WebClient(token=token) 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."""
durationPattern = re.compile(r"(?P<days>\d+d)?(?P<hours>\d+h)?(?P<minutes>\d+m)?")
matches = durationPattern.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: def get_configuration(path: str) -> Configuration:
"""Loads configuration from file.""" """Loads configuration from file."""
conf_path = pathlib.Path(path) conf_path = pathlib.Path(path)
@ -54,10 +74,16 @@ def get_configuration(path: str) -> Configuration:
@click.command() @click.command()
@click.option("--text", "-t", help="Status text.") @click.option("--text", "-t", help="Status text.")
@click.option("--emoji", "-e", help="Emoji attached to the status.") @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("--preset", "-p", help="Preset for text/emoji combinations.")
@click.option("--config", "-c", "config_path", help="Path to configuration.") @click.option("--config", "-c", "config_path", help="Path to configuration.")
def cli( def cli(
*, text: str = None, emoji: str = None, preset: str = None, config_path: str = None *,
text: str = None,
emoji: str = None,
duration: str = None,
preset: str = None,
config_path: str = None,
): ):
if text is None and preset is None: if text is None and preset is None:
raise RuntimeError( raise RuntimeError(
@ -66,12 +92,15 @@ def cli(
conf = get_configuration(config_path) conf = get_configuration(config_path)
status_text, status_emoji = None, None status_text, status_emoji, status_exp = None, None, 0
if preset is not None and preset in conf.presets: if preset is not None and preset in conf.presets:
preset_data = conf.presets[preset] preset_data = conf.presets[preset]
status_text = preset_data.text if preset_data.text else status_text 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_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: elif preset is not None:
raise RuntimeError(f"Unknown preset: {preset}") raise RuntimeError(f"Unknown preset: {preset}")
@ -81,7 +110,14 @@ def cli(
if emoji is not None: if emoji is not None:
status_emoji = emoji status_emoji = emoji
payload = ProfilePayload(status_text=status_text, status_emoji=status_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) client = get_client(conf.token)
api_response = client.users_profile_set(profile=payload.model_dump()) api_response = client.users_profile_set(profile=payload.model_dump())

View file

@ -1,6 +1,6 @@
[project] [project]
name = "slck" name = "slck"
version = "0.1.0" version = "0.2.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"
readme = "README.md" readme = "README.md"
dependencies = [ dependencies = [
@ -21,7 +21,8 @@ Homepage = "https://forge.karnov.club/marc/slck"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest~=8.0" "pytest~=8.0",
"freezegun",
] ]
[project.scripts] [project.scripts]

74
test.py
View file

@ -1,9 +1,11 @@
import datetime
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import freezegun
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from main import cli, get_configuration from main import cli, get_configuration, parse_duration
@pytest.fixture(name="with_sample_config") @pytest.fixture(name="with_sample_config")
@ -16,10 +18,30 @@ def fixture_with_sample_config(tmp_path):
test: test:
text: abc text: abc
emoji: ":tada:" 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(): def test_get_configuration_raises_if_noexist():
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
get_configuration("not/a/path.yml") get_configuration("not/a/path.yml")
@ -61,9 +83,7 @@ def test_cli_overrides_preset_with_text_input(with_sample_config, tmp_path):
mock_client = Mock() mock_client = Mock()
mock_client.users_profile_set = Mock() mock_client.users_profile_set = Mock()
with patch( with patch("main.get_client", autospec=True, return_value=mock_client):
"main.get_client", autospec=True, return_value=mock_client
):
result = runner.invoke( result = runner.invoke(
cli, cli,
[ [
@ -88,9 +108,7 @@ def test_cli_overrides_preset_with_emoji_input(with_sample_config, tmp_path):
mock_client = Mock() mock_client = Mock()
mock_client.users_profile_set = Mock() mock_client.users_profile_set = Mock()
with patch( with patch("main.get_client", autospec=True, return_value=mock_client):
"main.get_client", autospec=True, return_value=mock_client
):
result = runner.invoke( result = runner.invoke(
cli, cli,
[ [
@ -110,14 +128,42 @@ def test_cli_overrides_preset_with_emoji_input(with_sample_config, tmp_path):
assert call_args.kwargs["profile"]["status_emoji"] == ":skull:" assert call_args.kwargs["profile"]["status_emoji"] == ":skull:"
@freezegun.freeze_time("2012-01-01")
def test_cli_overrides_preset_with_exp_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,
[
"--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, with_sample_config): def test_cli_raises_if_noexist_preset(tmp_path, with_sample_config):
runner = CliRunner() runner = CliRunner()
mock_client = Mock() mock_client = Mock()
mock_client.users_profile_set = Mock() mock_client.users_profile_set = Mock()
with patch( with patch("main.get_client", autospec=True, return_value=mock_client):
"main.get_client", autospec=True, return_value=mock_client
):
result = runner.invoke( result = runner.invoke(
cli, ["--preset", "not-a-preset", "--config", tmp_path / "config.yml"] cli, ["--preset", "not-a-preset", "--config", tmp_path / "config.yml"]
) )
@ -131,9 +177,7 @@ def test_cli_sends_request_to_slack(tmp_path, with_sample_config):
mock_client = Mock() mock_client = Mock()
mock_client.users_profile_set = Mock() mock_client.users_profile_set = Mock()
with patch( with patch("main.get_client", autospec=True, return_value=mock_client):
"main.get_client", autospec=True, return_value=mock_client
):
runner.invoke( runner.invoke(
cli, cli,
[ [
@ -154,9 +198,7 @@ def test_cli_raises_if_api_error(tmp_path, with_sample_config):
mock_client = Mock() mock_client = Mock()
mock_client.users_profile_set = Mock(return_value={"ok": False}) mock_client.users_profile_set = Mock(return_value={"ok": False})
with patch( with patch("main.get_client", autospec=True, return_value=mock_client):
"main.get_client", autospec=True, return_value=mock_client
):
result = runner.invoke( result = runner.invoke(
cli, cli,
[ [