feat: add status expiration support
This commit is contained in:
parent
f80bc52622
commit
c43a99045a
3 changed files with 104 additions and 25 deletions
50
main.py
50
main.py
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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
74
test.py
|
@ -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,
|
||||||
[
|
[
|
||||||
|
|
Loading…
Reference in a new issue