From c43a99045a7b5a2f318b910e81c7f0b4438cbacd Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 15 Sep 2024 15:46:45 -0400 Subject: [PATCH] feat: add status expiration support --- main.py | 50 +++++++++++++++++++++++++++++----- pyproject.toml | 5 ++-- test.py | 74 +++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 104 insertions(+), 25 deletions(-) diff --git a/main.py b/main.py index 31856d3..9d9ad03 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,9 @@ Facilitates setting Slack status text/emojis via the command-line. """ +import datetime import pathlib +import re import click import pydantic @@ -17,22 +19,23 @@ class ProfilePayload(pydantic.BaseModel): """Profile payload sent to Slack's API.""" status_text: str = "" - status_emoji: str = "" - status_expiration: int = 0 + 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 = "" + emoji: str | None = "" + duration: str | None = "" class Configuration(pydantic.BaseModel): """Tool configuration.""" token: str - presets: dict[str, Preset] + presets: dict[str, Preset] | None 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) +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\d+d)?(?P\d+h)?(?P\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: """Loads configuration from file.""" conf_path = pathlib.Path(path) @@ -54,10 +74,16 @@ def get_configuration(path: str) -> Configuration: @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, 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: raise RuntimeError( @@ -66,12 +92,15 @@ def cli( 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: 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}") @@ -81,7 +110,14 @@ def cli( if emoji is not None: 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) api_response = client.users_profile_set(profile=payload.model_dump()) diff --git a/pyproject.toml b/pyproject.toml index 29685ef..001b44e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] 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" readme = "README.md" dependencies = [ @@ -21,7 +21,8 @@ Homepage = "https://forge.karnov.club/marc/slck" [project.optional-dependencies] dev = [ - "pytest~=8.0" + "pytest~=8.0", + "freezegun", ] [project.scripts] diff --git a/test.py b/test.py index 0c69317..fe4cb01 100644 --- a/test.py +++ b/test.py @@ -1,9 +1,11 @@ +import datetime from unittest.mock import Mock, patch +import freezegun import pytest 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") @@ -16,10 +18,30 @@ def fixture_with_sample_config(tmp_path): 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") @@ -61,9 +83,7 @@ def test_cli_overrides_preset_with_text_input(with_sample_config, tmp_path): mock_client = Mock() mock_client.users_profile_set = Mock() - with patch( - "main.get_client", autospec=True, return_value=mock_client - ): + with patch("main.get_client", autospec=True, return_value=mock_client): result = runner.invoke( cli, [ @@ -88,9 +108,7 @@ def test_cli_overrides_preset_with_emoji_input(with_sample_config, tmp_path): mock_client = Mock() mock_client.users_profile_set = Mock() - with patch( - "main.get_client", autospec=True, return_value=mock_client - ): + with patch("main.get_client", autospec=True, return_value=mock_client): result = runner.invoke( 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:" +@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): runner = CliRunner() mock_client = Mock() mock_client.users_profile_set = Mock() - with patch( - "main.get_client", autospec=True, return_value=mock_client - ): + with patch("main.get_client", autospec=True, return_value=mock_client): result = runner.invoke( 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.users_profile_set = Mock() - with patch( - "main.get_client", autospec=True, return_value=mock_client - ): + with patch("main.get_client", autospec=True, return_value=mock_client): runner.invoke( cli, [ @@ -154,9 +198,7 @@ def test_cli_raises_if_api_error(tmp_path, with_sample_config): mock_client = Mock() mock_client.users_profile_set = Mock(return_value={"ok": False}) - with patch( - "main.get_client", autospec=True, return_value=mock_client - ): + with patch("main.get_client", autospec=True, return_value=mock_client): result = runner.invoke( cli, [