From b215279a3cf8f6d32b69d3468051c472834e1af7 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:08:15 +0400 Subject: [PATCH] feat(telegram): improve command planning and testability (#158) --- .gitignore | 2 + Justfile | 3 + src/takopi/telegram/commands/overrides.py | 42 ++++-- src/takopi/telegram/commands/plan.py | 16 +++ src/takopi/telegram/commands/trigger.py | 94 ++++++++----- src/takopi/telegram/loop.py | 28 +++- src/takopi/telegram/onboarding.py | 20 ++- src/takopi/telegram/parsing.py | 5 +- tests/test_telegram_agent_trigger_commands.py | 59 ++++++++ tests/test_telegram_polling.py | 51 +++++++ uv.lock | 129 ++++++++++++++++++ 11 files changed, 398 insertions(+), 51 deletions(-) create mode 100644 src/takopi/telegram/commands/plan.py create mode 100644 tests/test_telegram_polling.py diff --git a/.gitignore b/.gitignore index 6548012..3643c60 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ __pycache__/ .pytest_cache/ .ruff_cache/ .coverage +.mutmut-cache/ +mutants/ .worktrees/ research/ _site/ diff --git a/Justfile b/Justfile index a4a7960..86de7e7 100644 --- a/Justfile +++ b/Justfile @@ -4,6 +4,9 @@ check: uv run ty check src tests uv run pytest +mutate: + uv run mutmut run + docs-serve: uv run --no-sync python scripts/docs_prebuild.py uv run --group docs zensical serve diff --git a/src/takopi/telegram/commands/overrides.py b/src/takopi/telegram/commands/overrides.py index 0176844..93f2e71 100644 --- a/src/takopi/telegram/commands/overrides.py +++ b/src/takopi/telegram/commands/overrides.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import TYPE_CHECKING, Literal from ...context import RunContext @@ -38,20 +39,45 @@ async def require_admin_or_private( denied: str, ) -> bool: reply = make_reply(cfg, msg) + decision = await check_admin_or_private( + cfg, + msg, + missing_sender=missing_sender, + failed_member=failed_member, + denied=denied, + ) + if decision.allowed: + return True + if decision.error_text is not None: + await reply(text=decision.error_text) + return False + + +@dataclass(frozen=True, slots=True) +class PermissionDecision: + allowed: bool + error_text: str | None = None + + +async def check_admin_or_private( + cfg: TelegramBridgeConfig, + msg: TelegramIncomingMessage, + *, + missing_sender: str, + failed_member: str, + denied: str, +) -> PermissionDecision: sender_id = msg.sender_id if sender_id is None: - await reply(text=missing_sender) - return False + return PermissionDecision(allowed=False, error_text=missing_sender) if msg.is_private: - return True + return PermissionDecision(allowed=True) member = await cfg.bot.get_chat_member(msg.chat_id, sender_id) if member is None: - await reply(text=failed_member) - return False + return PermissionDecision(allowed=False, error_text=failed_member) if member.status in {"creator", "administrator"}: - return True - await reply(text=denied) - return False + return PermissionDecision(allowed=True) + return PermissionDecision(allowed=False, error_text=denied) async def resolve_engine_selection( diff --git a/src/takopi/telegram/commands/plan.py b/src/takopi/telegram/commands/plan.py new file mode 100644 index 0000000..dbb0c38 --- /dev/null +++ b/src/takopi/telegram/commands/plan.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ActionPlan: + reply_text: str | None + actions: tuple[Callable[[], Awaitable[None]], ...] = () + + async def execute(self, reply: Callable[..., Awaitable[None]]) -> None: + for action in self.actions: + await action() + if self.reply_text is not None: + await reply(text=self.reply_text) diff --git a/src/takopi/telegram/commands/trigger.py b/src/takopi/telegram/commands/trigger.py index b36137f..5fcc522 100644 --- a/src/takopi/telegram/commands/trigger.py +++ b/src/takopi/telegram/commands/trigger.py @@ -8,7 +8,8 @@ from ..topic_state import TopicStateStore from ..topics import _topic_key from ..trigger_mode import resolve_trigger_mode from ..types import TelegramIncomingMessage -from .overrides import require_admin_or_private +from .overrides import check_admin_or_private +from .plan import ActionPlan from .reply import make_reply if TYPE_CHECKING: @@ -31,11 +32,27 @@ async def _handle_trigger_command( scope_chat_ids: frozenset[int] | None = None, ) -> None: reply = make_reply(cfg, msg) - tkey = ( - _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids) - if topic_store is not None - else None + plan = await _plan_trigger_command( + cfg, + msg, + args_text=args_text, + topic_store=topic_store, + chat_prefs=chat_prefs, + scope_chat_ids=scope_chat_ids, ) + await plan.execute(reply) + + +async def _plan_trigger_command( + cfg: TelegramBridgeConfig, + msg: TelegramIncomingMessage, + *, + args_text: str, + topic_store: TopicStateStore | None, + chat_prefs: ChatPrefsStore | None, + scope_chat_ids: frozenset[int] | None, +) -> ActionPlan: + tkey = _topic_key(msg, cfg, scope_chat_ids=scope_chat_ids) tokens = split_command_args(args_text) action = tokens[0].lower() if tokens else "show" @@ -65,53 +82,62 @@ async def _handle_trigger_command( chat_label = "unavailable" if chat_prefs is None else chat_mode or "none" defaults_line = f"defaults: topic: {topic_label}, chat: {chat_label}" available_line = "available: all, mentions" - await reply(text="\n\n".join([trigger_line, defaults_line, available_line])) - return + return ActionPlan( + reply_text="\n\n".join([trigger_line, defaults_line, available_line]) + ) if action in {"all", "mentions"}: - if not await require_admin_or_private( + decision = await check_admin_or_private( cfg, msg, missing_sender="cannot verify sender for trigger settings.", failed_member="failed to verify trigger permissions.", denied="changing trigger mode is restricted to group admins.", - ): - return + ) + if not decision.allowed: + return ActionPlan(reply_text=decision.error_text or TRIGGER_USAGE) if tkey is not None: if topic_store is None: - await reply(text="topic trigger settings are unavailable.") - return - await topic_store.set_trigger_mode(tkey[0], tkey[1], action) - await reply(text=f"topic trigger mode set to `{action}`") - return + return ActionPlan(reply_text="topic trigger settings are unavailable.") + return ActionPlan( + reply_text=f"topic trigger mode set to `{action}`", + actions=( + lambda: topic_store.set_trigger_mode(tkey[0], tkey[1], action), + ), + ) if chat_prefs is None: - await reply(text="chat trigger settings are unavailable (no config path).") - return - await chat_prefs.set_trigger_mode(msg.chat_id, action) - await reply(text=f"chat trigger mode set to `{action}`") - return + return ActionPlan( + reply_text="chat trigger settings are unavailable (no config path)." + ) + return ActionPlan( + reply_text=f"chat trigger mode set to `{action}`", + actions=(lambda: chat_prefs.set_trigger_mode(msg.chat_id, action),), + ) if action == "clear": - if not await require_admin_or_private( + decision = await check_admin_or_private( cfg, msg, missing_sender="cannot verify sender for trigger settings.", failed_member="failed to verify trigger permissions.", denied="changing trigger mode is restricted to group admins.", - ): - return + ) + if not decision.allowed: + return ActionPlan(reply_text=decision.error_text or TRIGGER_USAGE) if tkey is not None: if topic_store is None: - await reply(text="topic trigger settings are unavailable.") - return - await topic_store.clear_trigger_mode(tkey[0], tkey[1]) - await reply(text="topic trigger mode cleared (using chat default).") - return + return ActionPlan(reply_text="topic trigger settings are unavailable.") + return ActionPlan( + reply_text="topic trigger mode cleared (using chat default).", + actions=(lambda: topic_store.clear_trigger_mode(tkey[0], tkey[1]),), + ) if chat_prefs is None: - await reply(text="chat trigger settings are unavailable (no config path).") - return - await chat_prefs.clear_trigger_mode(msg.chat_id) - await reply(text="chat trigger mode reset to `all`.") - return + return ActionPlan( + reply_text="chat trigger settings are unavailable (no config path)." + ) + return ActionPlan( + reply_text="chat trigger mode reset to `all`.", + actions=(lambda: chat_prefs.clear_trigger_mode(msg.chat_id),), + ) - await reply(text=TRIGGER_USAGE) + return ActionPlan(reply_text=TRIGGER_USAGE) diff --git a/src/takopi/telegram/loop.py b/src/takopi/telegram/loop.py index 6511c7c..46dc7d4 100644 --- a/src/takopi/telegram/loop.py +++ b/src/takopi/telegram/loop.py @@ -290,6 +290,8 @@ async def _drain_backlog(cfg: TelegramBridgeConfig, offset: int | None) -> int | async def poll_updates( cfg: TelegramBridgeConfig, + *, + sleep: Callable[[float], Awaitable[None]] = anyio.sleep, ) -> AsyncIterator[TelegramIncomingUpdate]: offset: int | None = None offset = await _drain_backlog(cfg, offset) @@ -299,6 +301,7 @@ async def poll_updates( cfg.bot, chat_ids=lambda: _allowed_chat_ids(cfg), offset=offset, + sleep=sleep, ): yield msg @@ -419,11 +422,13 @@ class ForwardCoalescer: *, task_group: TaskGroup, debounce_s: float, + sleep: Callable[[float], Awaitable[None]] = anyio.sleep, dispatch: Callable[[_PendingPrompt], Awaitable[None]], pending: dict[ForwardKey, _PendingPrompt], ) -> None: self._task_group = task_group self._debounce_s = debounce_s + self._sleep = sleep self._dispatch = dispatch self._pending = pending @@ -556,7 +561,7 @@ class ForwardCoalescer: try: with anyio.CancelScope() as scope: pending.cancel_scope = scope - await anyio.sleep(self._debounce_s) + await self._sleep(self._debounce_s) except anyio.get_cancelled_exc_class(): return if self._pending.get(key) is not pending: @@ -673,6 +678,7 @@ class MediaGroupBuffer: *, task_group: TaskGroup, debounce_s: float, + sleep: Callable[[float], Awaitable[None]] = anyio.sleep, cfg: TelegramBridgeConfig, chat_prefs: ChatPrefsStore | None, topic_store: TopicStateStore | None, @@ -690,6 +696,7 @@ class MediaGroupBuffer: ) -> None: self._task_group = task_group self._debounce_s = debounce_s + self._sleep = sleep self._cfg = cfg self._chat_prefs = chat_prefs self._topic_store = topic_store @@ -718,7 +725,7 @@ class MediaGroupBuffer: if state is None: return token = state.token - await anyio.sleep(self._debounce_s) + await self._sleep(self._debounce_s) state = self._groups.get(key) if state is None: return @@ -880,6 +887,7 @@ async def run_main_loop( default_engine_override: str | None = None, transport_id: str | None = None, transport_config: TelegramTransportSettings | None = None, + sleep: Callable[[float], Awaitable[None]] = anyio.sleep, ) -> None: state = TelegramLoopState( running_tasks={}, @@ -971,6 +979,18 @@ async def run_main_loop( else: logger.info("trigger_mode.bot_username.unavailable") async with anyio.create_task_group() as tg: + poller_fn: Callable[ + [TelegramBridgeConfig], AsyncIterator[TelegramIncomingUpdate] + ] + if poller is poll_updates: + poller_fn = cast( + Callable[ + [TelegramBridgeConfig], AsyncIterator[TelegramIncomingUpdate] + ], + partial(poll_updates, sleep=sleep), + ) + else: + poller_fn = poller config_path = cfg.runtime.config_path watch_enabled = bool(watch_config) and config_path is not None @@ -1418,6 +1438,7 @@ async def run_main_loop( forward_coalescer = ForwardCoalescer( task_group=tg, debounce_s=state.forward_coalesce_s, + sleep=sleep, dispatch=_dispatch_pending_prompt, pending=state.pending_prompts, ) @@ -1451,6 +1472,7 @@ async def run_main_loop( media_group_buffer = MediaGroupBuffer( task_group=tg, debounce_s=state.media_group_debounce_s, + sleep=sleep, cfg=cfg, chat_prefs=state.chat_prefs, topic_store=state.topic_store, @@ -1736,7 +1758,7 @@ async def run_main_loop( return await route_message(update) - async for update in poller(cfg): + async for update in poller_fn(cfg): await route_update(update) finally: await cfg.exec_cfg.transport.close() diff --git a/src/takopi/telegram/onboarding.py b/src/takopi/telegram/onboarding.py index 9fa8c3e..02ceb2f 100644 --- a/src/takopi/telegram/onboarding.py +++ b/src/takopi/telegram/onboarding.py @@ -232,20 +232,32 @@ def mask_token(token: str) -> str: return f"{token[:9]}...{token[-5:]}" -async def get_bot_info(token: str) -> User | None: +async def get_bot_info( + token: str, + *, + sleep: Callable[[float], Awaitable[None]] | None = None, +) -> User | None: + if sleep is None: + sleep = anyio.sleep bot = TelegramClient(token) try: for _ in range(3): try: return await bot.get_me() except TelegramRetryAfter as exc: - await anyio.sleep(exc.retry_after) + await sleep(exc.retry_after) return None finally: await bot.close() -async def wait_for_chat(token: str) -> ChatInfo: +async def wait_for_chat( + token: str, + *, + sleep: Callable[[float], Awaitable[None]] | None = None, +) -> ChatInfo: + if sleep is None: + sleep = anyio.sleep bot = TelegramClient(token) try: offset: int | None = None @@ -260,7 +272,7 @@ async def wait_for_chat(token: str) -> ChatInfo: offset=offset, timeout_s=50, allowed_updates=allowed_updates ) if updates is None: - await anyio.sleep(1) + await sleep(1) continue if not updates: continue diff --git a/src/takopi/telegram/parsing.py b/src/takopi/telegram/parsing.py index 729ace3..328d84b 100644 --- a/src/takopi/telegram/parsing.py +++ b/src/takopi/telegram/parsing.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Iterable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable import anyio import msgspec @@ -210,6 +210,7 @@ async def poll_incoming( chat_id: int | None = None, chat_ids: Iterable[int] | Callable[[], Iterable[int]] | None = None, offset: int | None = None, + sleep: Callable[[float], Awaitable[None]] = anyio.sleep, ) -> AsyncIterator[TelegramIncomingUpdate]: while True: updates = await bot.get_updates( @@ -219,7 +220,7 @@ async def poll_incoming( ) if updates is None: logger.info("loop.get_updates.failed") - await anyio.sleep(2) + await sleep(2) continue logger.debug("loop.updates", updates=updates) resolved_chat_ids = chat_ids() if callable(chat_ids) else chat_ids diff --git a/tests/test_telegram_agent_trigger_commands.py b/tests/test_telegram_agent_trigger_commands.py index 8e4dddc..d6dc8a0 100644 --- a/tests/test_telegram_agent_trigger_commands.py +++ b/tests/test_telegram_agent_trigger_commands.py @@ -223,3 +223,62 @@ async def test_trigger_set_clear_permissions(tmp_path: Path) -> None: ) assert await prefs.get_trigger_mode(msg.chat_id) is None assert "chat trigger mode reset" in _last_text(transport) + + +@pytest.mark.anyio +async def test_trigger_missing_sender_denied(tmp_path: Path) -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + prefs = ChatPrefsStore(tmp_path / "prefs.json") + msg = _msg("/trigger all", chat_type="supergroup", sender_id=None) + + await _handle_trigger_command( + cfg, + msg, + args_text="all", + _ambient_context=None, + topic_store=None, + chat_prefs=prefs, + ) + + assert await prefs.get_trigger_mode(msg.chat_id) is None + assert "cannot verify sender" in _last_text(transport) + + +@pytest.mark.anyio +async def test_trigger_topic_unavailable() -> None: + transport = FakeTransport() + cfg = replace( + make_cfg(transport), + topics=TelegramTopicsSettings(enabled=True, scope="all"), + ) + msg = _msg("/trigger mentions", chat_type="supergroup", thread_id=3) + + await _handle_trigger_command( + cfg, + msg, + args_text="mentions", + _ambient_context=None, + topic_store=None, + chat_prefs=None, + ) + + assert "topic trigger settings are unavailable" in _last_text(transport) + + +@pytest.mark.anyio +async def test_trigger_chat_prefs_unavailable() -> None: + transport = FakeTransport() + cfg = make_cfg(transport) + msg = _msg("/trigger mentions", chat_type="supergroup") + + await _handle_trigger_command( + cfg, + msg, + args_text="mentions", + _ambient_context=None, + topic_store=None, + chat_prefs=None, + ) + + assert "chat trigger settings are unavailable" in _last_text(transport) diff --git a/tests/test_telegram_polling.py b/tests/test_telegram_polling.py new file mode 100644 index 0000000..212c78b --- /dev/null +++ b/tests/test_telegram_polling.py @@ -0,0 +1,51 @@ +import pytest + +from takopi.telegram.api_models import Chat, Message, Update, User +from takopi.telegram.parsing import poll_incoming +from tests.telegram_fakes import FakeBot + + +class _Bot(FakeBot): + def __init__(self) -> None: + super().__init__() + self.calls = 0 + + async def get_updates( + self, + offset: int | None, + timeout_s: int = 50, + allowed_updates: list[str] | None = None, + ) -> list[Update] | None: + _ = offset, timeout_s, allowed_updates + self.calls += 1 + if self.calls == 1: + return None + return [ + Update( + update_id=1, + message=Message( + message_id=10, + text="hello", + chat=Chat(id=123, type="private"), + from_=User(id=9), + ), + ) + ] + + +@pytest.mark.anyio +async def test_poll_incoming_retries_on_none() -> None: + bot = _Bot() + sleeps: list[float] = [] + + async def sleep(delay: float) -> None: + sleeps.append(delay) + + msg = None + async for update in poll_incoming(bot, sleep=sleep): + msg = update + break + + assert sleeps == [2] + assert msg is not None + assert msg.chat_id == 123 diff --git a/uv.lock b/uv.lock index 2b0b731..67e5132 100644 --- a/uv.lock +++ b/uv.lock @@ -243,6 +243,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, ] +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -308,6 +347,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -338,6 +382,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -463,6 +519,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" }, ] +[[package]] +name = "mutmut" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/b2/813295383dacdcd05dd9104f462fa549c8213153340315be3c93bf999cf6/mutmut-3.4.0.tar.gz", hash = "sha256:b3b47e60828192c9f2e7737316469777f769a9163d0e384776129b80f7e8aa3e", size = 31922, upload-time = "2025-11-19T09:28:07.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/bf/391def84fbb3269dcb4322cb380f2bbd189c609464300d974dfdc00e491d/mutmut-3.4.0-py3-none-any.whl", hash = "sha256:77233282c0cfb198c0605d640aacf26f8c6f2540cec15a940e5c8f144672cd2f", size = 29628, upload-time = "2025-11-19T09:28:05.64Z" }, +] + [[package]] name = "openai" version = "2.15.0" @@ -773,6 +846,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -845,6 +946,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "mutmut" }, { name = "pytest" }, { name = "pytest-anyio" }, { name = "pytest-cov" }, @@ -876,6 +978,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "mutmut", specifier = ">=3.4.0" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-anyio", specifier = ">=0.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, @@ -887,6 +990,23 @@ docs = [ { name = "zensical", specifier = ">=0.0.15" }, ] +[[package]] +name = "textual" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/ee/620c887bfad9d6eba062dfa3b6b0e735e0259102e2667b19f21625ef598d/textual-7.3.0.tar.gz", hash = "sha256:3169e8ba5518a979b0771e60be380ab1a6c344f30a2126e360e6f38d009a3de4", size = 1590692, upload-time = "2026-01-15T16:32:02.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/1f/abeb4e5cb36b99dd37db72beb2a74d58598ccb35aaadf14624ee967d4a6b/textual-7.3.0-py3-none-any.whl", hash = "sha256:db235cecf969c87fe5a9c04d83595f506affc9db81f3a53ab849534d726d330a", size = 716374, upload-time = "2026-01-15T16:31:58.233Z" }, +] + [[package]] name = "tomli-w" version = "1.2.0" @@ -969,6 +1089,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"