feat(telegram): improve command planning and testability (#158)

This commit is contained in:
banteg
2026-01-17 01:08:15 +04:00
committed by GitHub
parent 2b5b2fa6b1
commit b215279a3c
11 changed files with 398 additions and 51 deletions
+2
View File
@@ -5,6 +5,8 @@ __pycache__/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
.coverage .coverage
.mutmut-cache/
mutants/
.worktrees/ .worktrees/
research/ research/
_site/ _site/
+3
View File
@@ -4,6 +4,9 @@ check:
uv run ty check src tests uv run ty check src tests
uv run pytest uv run pytest
mutate:
uv run mutmut run
docs-serve: docs-serve:
uv run --no-sync python scripts/docs_prebuild.py uv run --no-sync python scripts/docs_prebuild.py
uv run --group docs zensical serve uv run --group docs zensical serve
+34 -8
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from ...context import RunContext from ...context import RunContext
@@ -38,20 +39,45 @@ async def require_admin_or_private(
denied: str, denied: str,
) -> bool: ) -> bool:
reply = make_reply(cfg, msg) 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 sender_id = msg.sender_id
if sender_id is None: if sender_id is None:
await reply(text=missing_sender) return PermissionDecision(allowed=False, error_text=missing_sender)
return False
if msg.is_private: if msg.is_private:
return True return PermissionDecision(allowed=True)
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id) member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
if member is None: if member is None:
await reply(text=failed_member) return PermissionDecision(allowed=False, error_text=failed_member)
return False
if member.status in {"creator", "administrator"}: if member.status in {"creator", "administrator"}:
return True return PermissionDecision(allowed=True)
await reply(text=denied) return PermissionDecision(allowed=False, error_text=denied)
return False
async def resolve_engine_selection( async def resolve_engine_selection(
+16
View File
@@ -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)
+60 -34
View File
@@ -8,7 +8,8 @@ from ..topic_state import TopicStateStore
from ..topics import _topic_key from ..topics import _topic_key
from ..trigger_mode import resolve_trigger_mode from ..trigger_mode import resolve_trigger_mode
from ..types import TelegramIncomingMessage 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 from .reply import make_reply
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -31,11 +32,27 @@ async def _handle_trigger_command(
scope_chat_ids: frozenset[int] | None = None, scope_chat_ids: frozenset[int] | None = None,
) -> None: ) -> None:
reply = make_reply(cfg, msg) reply = make_reply(cfg, msg)
tkey = ( plan = await _plan_trigger_command(
_topic_key(msg, cfg, scope_chat_ids=scope_chat_ids) cfg,
if topic_store is not None msg,
else None 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) tokens = split_command_args(args_text)
action = tokens[0].lower() if tokens else "show" 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" chat_label = "unavailable" if chat_prefs is None else chat_mode or "none"
defaults_line = f"defaults: topic: {topic_label}, chat: {chat_label}" defaults_line = f"defaults: topic: {topic_label}, chat: {chat_label}"
available_line = "available: all, mentions" available_line = "available: all, mentions"
await reply(text="\n\n".join([trigger_line, defaults_line, available_line])) return ActionPlan(
return reply_text="\n\n".join([trigger_line, defaults_line, available_line])
)
if action in {"all", "mentions"}: if action in {"all", "mentions"}:
if not await require_admin_or_private( decision = await check_admin_or_private(
cfg, cfg,
msg, msg,
missing_sender="cannot verify sender for trigger settings.", missing_sender="cannot verify sender for trigger settings.",
failed_member="failed to verify trigger permissions.", failed_member="failed to verify trigger permissions.",
denied="changing trigger mode is restricted to group admins.", 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 tkey is not None:
if topic_store is None: if topic_store is None:
await reply(text="topic trigger settings are unavailable.") return ActionPlan(reply_text="topic trigger settings are unavailable.")
return return ActionPlan(
await topic_store.set_trigger_mode(tkey[0], tkey[1], action) reply_text=f"topic trigger mode set to `{action}`",
await reply(text=f"topic trigger mode set to `{action}`") actions=(
return lambda: topic_store.set_trigger_mode(tkey[0], tkey[1], action),
),
)
if chat_prefs is None: if chat_prefs is None:
await reply(text="chat trigger settings are unavailable (no config path).") return ActionPlan(
return reply_text="chat trigger settings are unavailable (no config path)."
await chat_prefs.set_trigger_mode(msg.chat_id, action) )
await reply(text=f"chat trigger mode set to `{action}`") return ActionPlan(
return reply_text=f"chat trigger mode set to `{action}`",
actions=(lambda: chat_prefs.set_trigger_mode(msg.chat_id, action),),
)
if action == "clear": if action == "clear":
if not await require_admin_or_private( decision = await check_admin_or_private(
cfg, cfg,
msg, msg,
missing_sender="cannot verify sender for trigger settings.", missing_sender="cannot verify sender for trigger settings.",
failed_member="failed to verify trigger permissions.", failed_member="failed to verify trigger permissions.",
denied="changing trigger mode is restricted to group admins.", 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 tkey is not None:
if topic_store is None: if topic_store is None:
await reply(text="topic trigger settings are unavailable.") return ActionPlan(reply_text="topic trigger settings are unavailable.")
return return ActionPlan(
await topic_store.clear_trigger_mode(tkey[0], tkey[1]) reply_text="topic trigger mode cleared (using chat default).",
await reply(text="topic trigger mode cleared (using chat default).") actions=(lambda: topic_store.clear_trigger_mode(tkey[0], tkey[1]),),
return )
if chat_prefs is None: if chat_prefs is None:
await reply(text="chat trigger settings are unavailable (no config path).") return ActionPlan(
return reply_text="chat trigger settings are unavailable (no config path)."
await chat_prefs.clear_trigger_mode(msg.chat_id) )
await reply(text="chat trigger mode reset to `all`.") return ActionPlan(
return 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)
+25 -3
View File
@@ -290,6 +290,8 @@ async def _drain_backlog(cfg: TelegramBridgeConfig, offset: int | None) -> int |
async def poll_updates( async def poll_updates(
cfg: TelegramBridgeConfig, cfg: TelegramBridgeConfig,
*,
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
) -> AsyncIterator[TelegramIncomingUpdate]: ) -> AsyncIterator[TelegramIncomingUpdate]:
offset: int | None = None offset: int | None = None
offset = await _drain_backlog(cfg, offset) offset = await _drain_backlog(cfg, offset)
@@ -299,6 +301,7 @@ async def poll_updates(
cfg.bot, cfg.bot,
chat_ids=lambda: _allowed_chat_ids(cfg), chat_ids=lambda: _allowed_chat_ids(cfg),
offset=offset, offset=offset,
sleep=sleep,
): ):
yield msg yield msg
@@ -419,11 +422,13 @@ class ForwardCoalescer:
*, *,
task_group: TaskGroup, task_group: TaskGroup,
debounce_s: float, debounce_s: float,
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
dispatch: Callable[[_PendingPrompt], Awaitable[None]], dispatch: Callable[[_PendingPrompt], Awaitable[None]],
pending: dict[ForwardKey, _PendingPrompt], pending: dict[ForwardKey, _PendingPrompt],
) -> None: ) -> None:
self._task_group = task_group self._task_group = task_group
self._debounce_s = debounce_s self._debounce_s = debounce_s
self._sleep = sleep
self._dispatch = dispatch self._dispatch = dispatch
self._pending = pending self._pending = pending
@@ -556,7 +561,7 @@ class ForwardCoalescer:
try: try:
with anyio.CancelScope() as scope: with anyio.CancelScope() as scope:
pending.cancel_scope = scope pending.cancel_scope = scope
await anyio.sleep(self._debounce_s) await self._sleep(self._debounce_s)
except anyio.get_cancelled_exc_class(): except anyio.get_cancelled_exc_class():
return return
if self._pending.get(key) is not pending: if self._pending.get(key) is not pending:
@@ -673,6 +678,7 @@ class MediaGroupBuffer:
*, *,
task_group: TaskGroup, task_group: TaskGroup,
debounce_s: float, debounce_s: float,
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
cfg: TelegramBridgeConfig, cfg: TelegramBridgeConfig,
chat_prefs: ChatPrefsStore | None, chat_prefs: ChatPrefsStore | None,
topic_store: TopicStateStore | None, topic_store: TopicStateStore | None,
@@ -690,6 +696,7 @@ class MediaGroupBuffer:
) -> None: ) -> None:
self._task_group = task_group self._task_group = task_group
self._debounce_s = debounce_s self._debounce_s = debounce_s
self._sleep = sleep
self._cfg = cfg self._cfg = cfg
self._chat_prefs = chat_prefs self._chat_prefs = chat_prefs
self._topic_store = topic_store self._topic_store = topic_store
@@ -718,7 +725,7 @@ class MediaGroupBuffer:
if state is None: if state is None:
return return
token = state.token token = state.token
await anyio.sleep(self._debounce_s) await self._sleep(self._debounce_s)
state = self._groups.get(key) state = self._groups.get(key)
if state is None: if state is None:
return return
@@ -880,6 +887,7 @@ async def run_main_loop(
default_engine_override: str | None = None, default_engine_override: str | None = None,
transport_id: str | None = None, transport_id: str | None = None,
transport_config: TelegramTransportSettings | None = None, transport_config: TelegramTransportSettings | None = None,
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
) -> None: ) -> None:
state = TelegramLoopState( state = TelegramLoopState(
running_tasks={}, running_tasks={},
@@ -971,6 +979,18 @@ async def run_main_loop(
else: else:
logger.info("trigger_mode.bot_username.unavailable") logger.info("trigger_mode.bot_username.unavailable")
async with anyio.create_task_group() as tg: 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 config_path = cfg.runtime.config_path
watch_enabled = bool(watch_config) and config_path is not None watch_enabled = bool(watch_config) and config_path is not None
@@ -1418,6 +1438,7 @@ async def run_main_loop(
forward_coalescer = ForwardCoalescer( forward_coalescer = ForwardCoalescer(
task_group=tg, task_group=tg,
debounce_s=state.forward_coalesce_s, debounce_s=state.forward_coalesce_s,
sleep=sleep,
dispatch=_dispatch_pending_prompt, dispatch=_dispatch_pending_prompt,
pending=state.pending_prompts, pending=state.pending_prompts,
) )
@@ -1451,6 +1472,7 @@ async def run_main_loop(
media_group_buffer = MediaGroupBuffer( media_group_buffer = MediaGroupBuffer(
task_group=tg, task_group=tg,
debounce_s=state.media_group_debounce_s, debounce_s=state.media_group_debounce_s,
sleep=sleep,
cfg=cfg, cfg=cfg,
chat_prefs=state.chat_prefs, chat_prefs=state.chat_prefs,
topic_store=state.topic_store, topic_store=state.topic_store,
@@ -1736,7 +1758,7 @@ async def run_main_loop(
return return
await route_message(update) await route_message(update)
async for update in poller(cfg): async for update in poller_fn(cfg):
await route_update(update) await route_update(update)
finally: finally:
await cfg.exec_cfg.transport.close() await cfg.exec_cfg.transport.close()
+16 -4
View File
@@ -232,20 +232,32 @@ def mask_token(token: str) -> str:
return f"{token[:9]}...{token[-5:]}" 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) bot = TelegramClient(token)
try: try:
for _ in range(3): for _ in range(3):
try: try:
return await bot.get_me() return await bot.get_me()
except TelegramRetryAfter as exc: except TelegramRetryAfter as exc:
await anyio.sleep(exc.retry_after) await sleep(exc.retry_after)
return None return None
finally: finally:
await bot.close() 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) bot = TelegramClient(token)
try: try:
offset: int | None = None 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 offset=offset, timeout_s=50, allowed_updates=allowed_updates
) )
if updates is None: if updates is None:
await anyio.sleep(1) await sleep(1)
continue continue
if not updates: if not updates:
continue continue
+3 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterable from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
import anyio import anyio
import msgspec import msgspec
@@ -210,6 +210,7 @@ async def poll_incoming(
chat_id: int | None = None, chat_id: int | None = None,
chat_ids: Iterable[int] | Callable[[], Iterable[int]] | None = None, chat_ids: Iterable[int] | Callable[[], Iterable[int]] | None = None,
offset: int | None = None, offset: int | None = None,
sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
) -> AsyncIterator[TelegramIncomingUpdate]: ) -> AsyncIterator[TelegramIncomingUpdate]:
while True: while True:
updates = await bot.get_updates( updates = await bot.get_updates(
@@ -219,7 +220,7 @@ async def poll_incoming(
) )
if updates is None: if updates is None:
logger.info("loop.get_updates.failed") logger.info("loop.get_updates.failed")
await anyio.sleep(2) await sleep(2)
continue continue
logger.debug("loop.updates", updates=updates) logger.debug("loop.updates", updates=updates)
resolved_chat_ids = chat_ids() if callable(chat_ids) else chat_ids resolved_chat_ids = chat_ids() if callable(chat_ids) else chat_ids
@@ -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 await prefs.get_trigger_mode(msg.chat_id) is None
assert "chat trigger mode reset" in _last_text(transport) 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)
+51
View File
@@ -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
Generated
+129
View File
@@ -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" }, { 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]] [[package]]
name = "lxml" name = "lxml"
version = "6.0.2" 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" }, { 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]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" 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" }, { 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]] [[package]]
name = "mdurl" name = "mdurl"
version = "0.1.2" 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" }, { 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]] [[package]]
name = "openai" name = "openai"
version = "2.15.0" 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" }, { 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]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"
@@ -845,6 +946,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "mutmut" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-anyio" }, { name = "pytest-anyio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
@@ -876,6 +978,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "mutmut", specifier = ">=3.4.0" },
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-anyio", specifier = ">=0.0.0" }, { name = "pytest-anyio", specifier = ">=0.0.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0" },
@@ -887,6 +990,23 @@ docs = [
{ name = "zensical", specifier = ">=0.0.15" }, { 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]] [[package]]
name = "tomli-w" name = "tomli-w"
version = "1.2.0" 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" }, { 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]] [[package]]
name = "watchdog" name = "watchdog"
version = "6.0.0" version = "6.0.0"