refactor: migrate exec bridge to anyio and harden cancellation (#6)

This commit is contained in:
banteg
2025-12-31 01:51:46 +04:00
committed by GitHub
parent 6687a435c9
commit 8eda3f5e84
9 changed files with 492 additions and 310 deletions
+3 -3
View File
@@ -44,8 +44,8 @@ The orchestrator module containing:
**Key patterns:** **Key patterns:**
- Per-session locks prevent concurrent resumes to the same `session_id` - Per-session locks prevent concurrent resumes to the same `session_id`
- Worker pool with `asyncio.Queue` limits concurrency (default: 16 workers) - Worker pool with an AnyIO memory stream limits concurrency (default: 16 workers)
- `asyncio.TaskGroup` manages worker tasks - AnyIO task groups manage worker tasks
- Progress edits are throttled to ~2s intervals - Progress edits are throttled to ~2s intervals
- Subprocess stderr is drained to a bounded deque for error reporting - Subprocess stderr is drained to a bounded deque for error reporting
- `poll_updates()` uses Telegram `getUpdates` long-polling with a single server-side updates - `poll_updates()` uses Telegram `getUpdates` long-polling with a single server-side updates
@@ -154,5 +154,5 @@ Same as above, but:
|----------|----------| |----------|----------|
| `codex exec` fails (rc≠0) | Shows stderr tail in error message | | `codex exec` fails (rc≠0) | Shows stderr tail in error message |
| Telegram API error | Logged, edit skipped (progress continues) | | Telegram API error | Logged, edit skipped (progress continues) |
| Cancellation | Subprocess terminated, CancelledError re-raised | | Cancellation | Cancel scope triggers terminate; cancellation is detected via `cancelled_caught` |
| No agent_message | Final shows "error" status | | No agent_message | Final shows "error" status |
+2
View File
@@ -7,6 +7,7 @@ readme = "readme.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"anyio>=4.12.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"markdown-it-py", "markdown-it-py",
"rich>=14.2.0", "rich>=14.2.0",
@@ -38,6 +39,7 @@ build-backend = "uv_build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=9.0.2", "pytest>=9.0.2",
"pytest-anyio>=0.0.0",
"pytest-cov>=7.0.0", "pytest-cov>=7.0.0",
"ruff>=0.14.10", "ruff>=0.14.10",
"ty>=0.0.8", "ty>=0.0.8",
+171 -100
View File
@@ -1,21 +1,24 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import inspect import inspect
import json import json
import logging import logging
import os import os
import re import re
import shutil import shutil
import subprocess
import time import time
from collections import deque from collections import deque
from collections.abc import Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, cast from typing import Any
from weakref import WeakValueDictionary from weakref import WeakValueDictionary
import anyio
import typer import typer
from anyio.abc import ByteReceiveStream, Process
from anyio.streams.text import TextReceiveStream
from . import __version__ from . import __version__
from .config import ConfigError, load_telegram_config from .config import ConfigError, load_telegram_config
@@ -61,32 +64,59 @@ def resolve_resume_session(text: str | None, reply_text: str | None) -> str | No
return extract_session_id(text) or extract_session_id(reply_text) return extract_session_id(text) or extract_session_id(reply_text)
async def _drain_stderr(stderr: asyncio.StreamReader, tail: deque[str]) -> None: async def _iter_text_lines(stream: ByteReceiveStream) -> AsyncIterator[str]:
try: text_stream = TextReceiveStream(stream, errors="replace")
buffer = ""
while True: while True:
line = await stderr.readline() try:
if not line: chunk = await text_stream.receive()
except anyio.EndOfStream:
if buffer:
yield buffer
return return
decoded = line.decode(errors="replace") buffer += chunk
logger.info("[codex][stderr] %s", decoded.rstrip()) while True:
tail.append(decoded) split_at = buffer.find("\n")
if split_at < 0:
break
line = buffer[: split_at + 1]
buffer = buffer[split_at + 1 :]
yield line
async def _drain_stderr(stderr: ByteReceiveStream, tail: deque[str]) -> None:
try:
async for line in _iter_text_lines(stderr):
logger.info("[codex][stderr] %s", line.rstrip())
tail.append(line)
except Exception as e: except Exception as e:
logger.debug("[codex][stderr] drain error: %s", e) logger.debug("[codex][stderr] drain error: %s", e)
async def _wait_for_process(proc: Process, timeout: float) -> bool:
with anyio.move_on_after(timeout) as scope:
await proc.wait()
return scope.cancel_called
@asynccontextmanager @asynccontextmanager
async def manage_subprocess(*args, **kwargs): async def manage_subprocess(*args, terminate_timeout: float = 2.0, **kwargs):
proc = await asyncio.create_subprocess_exec(*args, **kwargs) proc = await anyio.open_process(args, **kwargs)
try: try:
yield proc yield proc
finally: finally:
if proc.returncode is None: if proc.returncode is None:
proc.terminate() with anyio.CancelScope(shield=True):
try: try:
await asyncio.wait_for(proc.wait(), timeout=2.0) proc.terminate()
except asyncio.TimeoutError: except ProcessLookupError:
proc.kill() pass
await proc.wait() timed_out = await _wait_for_process(proc, terminate_timeout)
if timed_out:
logger.debug(
"[codex] terminate timed out pid=%s; leaving process to exit",
proc.pid,
)
TELEGRAM_MARKDOWN_LIMIT = 3500 TELEGRAM_MARKDOWN_LIMIT = 3500
@@ -212,17 +242,17 @@ class ProgressEdits:
self.last_rendered = last_rendered self.last_rendered = last_rendered
self._event_seq = 0 self._event_seq = 0
self._published_seq = 0 self._published_seq = 0
self.wakeup = asyncio.Event() self.wakeup = anyio.Event()
self.task: asyncio.Task[None] | None = (
asyncio.create_task(self.run()) if self.progress_id is not None else None async def _wait_for_wakeup(self) -> None:
) await self.wakeup.wait()
self.wakeup = anyio.Event()
async def run(self) -> None: async def run(self) -> None:
if self.progress_id is None: if self.progress_id is None:
return return
while True: while True:
await self.wakeup.wait() await self._wait_for_wakeup()
self.wakeup.clear()
while self._published_seq < self._event_seq: while self._published_seq < self._event_seq:
await self.sleep( await self.sleep(
max( max(
@@ -250,7 +280,6 @@ class ProgressEdits:
self.last_rendered = rendered self.last_rendered = rendered
self._published_seq = seq_at_render self._published_seq = seq_at_render
self.wakeup.clear()
async def on_event(self, evt: dict[str, Any]) -> None: async def on_event(self, evt: dict[str, Any]) -> None:
if not self.renderer.note_event(evt): if not self.renderer.note_event(evt):
@@ -260,12 +289,6 @@ class ProgressEdits:
self._event_seq += 1 self._event_seq += 1
self.wakeup.set() self.wakeup.set()
async def shutdown(self) -> None:
if self.task is None:
return
self.task.cancel()
await asyncio.gather(self.task, return_exceptions=True)
class CodexExecRunner: class CodexExecRunner:
def __init__( def __init__(
@@ -277,14 +300,14 @@ class CodexExecRunner:
self.extra_args = extra_args self.extra_args = extra_args
# Per-session locks to prevent concurrent resumes to the same session_id. # Per-session locks to prevent concurrent resumes to the same session_id.
self._session_locks: WeakValueDictionary[str, asyncio.Lock] = ( self._session_locks: WeakValueDictionary[str, anyio.Lock] = (
WeakValueDictionary() WeakValueDictionary()
) )
async def _lock_for(self, session_id: str) -> asyncio.Lock: async def _lock_for(self, session_id: str) -> anyio.Lock:
lock = self._session_locks.get(session_id) lock = self._session_locks.get(session_id)
if lock is None: if lock is None:
lock = asyncio.Lock() lock = anyio.Lock()
self._session_locks[session_id] = lock self._session_locks[session_id] = lock
return lock return lock
@@ -306,19 +329,23 @@ class CodexExecRunner:
else: else:
args.append("-") args.append("-")
cancelled_exc_type = anyio.get_cancelled_exc_class()
cancelled_exc: BaseException | None = None
async with manage_subprocess( async with manage_subprocess(
*args, *args,
stdin=asyncio.subprocess.PIPE, stdin=subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stdout=subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=subprocess.PIPE,
) as proc: ) as proc:
proc_stdin = cast(asyncio.StreamWriter, proc.stdin) if proc.stdin is None or proc.stdout is None or proc.stderr is None:
proc_stdout = cast(asyncio.StreamReader, proc.stdout) raise RuntimeError("codex exec failed to open subprocess pipes")
proc_stderr = cast(asyncio.StreamReader, proc.stderr) proc_stdin = proc.stdin
proc_stdout = proc.stdout
proc_stderr = proc.stderr
logger.debug("[codex] spawn pid=%s args=%r", proc.pid, args) logger.debug("[codex] spawn pid=%s args=%r", proc.pid, args)
stderr_tail: deque[str] = deque(maxlen=200) stderr_tail: deque[str] = deque(maxlen=200)
stderr_task = asyncio.create_task(_drain_stderr(proc_stderr, stderr_tail)) rc: int | None = None
found_session: str | None = session_id found_session: str | None = session_id
last_agent_text: str | None = None last_agent_text: str | None = None
@@ -326,16 +353,16 @@ class CodexExecRunner:
cli_last_item: int | None = None cli_last_item: int | None = None
cancelled = False cancelled = False
rc: int | None = None async with anyio.create_task_group() as tg:
tg.start_soon(_drain_stderr, proc_stderr, stderr_tail)
try: try:
proc_stdin.write(prompt.encode()) await proc_stdin.send(prompt.encode())
await proc_stdin.drain() await proc_stdin.aclose()
proc_stdin.close()
async for raw_line in proc_stdout: async for raw_line in _iter_text_lines(proc_stdout):
raw = raw_line.decode(errors="replace") raw = raw_line.rstrip("\n")
logger.debug("[codex][jsonl] %s", raw.rstrip("\n")) logger.debug("[codex][jsonl] %s", raw)
line = raw.strip() line = raw.strip()
if not line: if not line:
continue continue
@@ -367,21 +394,16 @@ class CodexExecRunner:
): ):
last_agent_text = item["text"] last_agent_text = item["text"]
saw_agent_message = True saw_agent_message = True
except asyncio.CancelledError: except cancelled_exc_type as exc:
cancelled = True cancelled = True
cancelled_exc = exc
tg.cancel_scope.cancel()
finally: finally:
if cancelled:
if not stderr_task.done():
stderr_task.cancel()
task = cast(asyncio.Task, asyncio.current_task())
while task.cancelling():
task.uncancel()
if not cancelled: if not cancelled:
rc = await proc.wait() rc = await proc.wait()
await asyncio.gather(stderr_task, return_exceptions=True)
if cancelled: if cancelled:
raise asyncio.CancelledError raise cancelled_exc # type: ignore[misc]
logger.debug("[codex] process exit pid=%s rc=%s", proc.pid, rc) logger.debug("[codex] process exit pid=%s rc=%s", proc.pid, rc)
if rc != 0: if rc != 0:
@@ -406,12 +428,32 @@ class CodexExecRunner:
session_id: str | None, session_id: str | None,
on_event: EventCallback | None = None, on_event: EventCallback | None = None,
) -> tuple[str, str, bool]: ) -> tuple[str, str, bool]:
if not session_id: if session_id:
return await self.run(prompt, session_id=None, on_event=on_event)
lock = await self._lock_for(session_id) lock = await self._lock_for(session_id)
async with lock: async with lock:
return await self.run(prompt, session_id=session_id, on_event=on_event) return await self.run(prompt, session_id=session_id, on_event=on_event)
session_lock: anyio.Lock | None = None
async def on_event_with_lock(evt: dict[str, Any]) -> None:
nonlocal session_lock
if session_lock is None and evt.get("type") == "thread.started":
thread_id = evt.get("thread_id")
if isinstance(thread_id, str) and thread_id:
session_lock = await self._lock_for(thread_id)
await session_lock.acquire()
if on_event is None:
return
res = on_event(evt)
if inspect.isawaitable(res):
await res
try:
return await self.run(prompt, session_id=None, on_event=on_event_with_lock)
finally:
if session_lock is not None:
session_lock.release()
@dataclass(frozen=True) @dataclass(frozen=True)
class BridgeConfig: class BridgeConfig:
@@ -423,6 +465,12 @@ class BridgeConfig:
max_concurrency: int max_concurrency: int
@dataclass
class RunningTask:
scope: anyio.CancelScope
session_id: str | None = None
def _parse_bridge_config( def _parse_bridge_config(
*, *,
final_notify: bool, final_notify: bool,
@@ -508,9 +556,9 @@ async def handle_message(
user_msg_id: int, user_msg_id: int,
text: str, text: str,
resume_session: str | None, resume_session: str | None,
running_tasks: dict[str, asyncio.Task[Any]] | None = None, running_tasks: dict[int, RunningTask] | None = None,
clock: Callable[[], float] = time.monotonic, clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
progress_edit_every: float = PROGRESS_EDIT_EVERY_S, progress_edit_every: float = PROGRESS_EDIT_EVERY_S,
) -> None: ) -> None:
logger.debug( logger.debug(
@@ -565,31 +613,54 @@ async def handle_message(
last_rendered=last_rendered, last_rendered=last_rendered,
) )
exec_task: asyncio.Task[tuple[str, str, bool]] | None = None exec_scope = anyio.CancelScope()
cancelled = False
error: Exception | None = None
session_id: str | None = None
answer: str | None = None
saw_agent_message: bool | None = None
running_task: RunningTask | None = None
if running_tasks is not None and progress_id is not None:
running_task = RunningTask(scope=exec_scope)
running_tasks[progress_id] = running_task
if resume_session is not None:
running_task.session_id = resume_session
async def on_event(evt: dict[str, Any]) -> None: async def on_event(evt: dict[str, Any]) -> None:
if ( if (
evt["type"] == "thread.started" running_task is not None
and running_tasks is not None and running_task.session_id is None
and exec_task is not None and evt.get("type") == "thread.started"
): ):
running_tasks[evt["thread_id"]] = exec_task thread_id = evt.get("thread_id")
if isinstance(thread_id, str) and thread_id:
running_task.session_id = thread_id
await edits.on_event(evt) await edits.on_event(evt)
exec_task = asyncio.create_task( async with anyio.create_task_group() as tg:
cfg.runner.run_serialized(text, resume_session, on_event=on_event) if progress_id is not None:
) tg.start_soon(edits.run)
cancelled = False
try: try:
session_id, answer, saw_agent_message = await exec_task with exec_scope:
except asyncio.CancelledError: session_id, answer, saw_agent_message = await cfg.runner.run_serialized(
text, resume_session, on_event=on_event
)
except Exception as e:
error = e
finally:
if running_task is not None:
if running_tasks is not None and progress_id is not None:
running_tasks.pop(progress_id, None)
if exec_scope.cancelled_caught and not cancelled and error is None:
cancelled = True cancelled = True
session_id = progress_renderer.resume_session or resume_session session_id = progress_renderer.resume_session or resume_session
except Exception as e: if not cancelled and error is None:
await edits.shutdown() await anyio.sleep(0)
tg.cancel_scope.cancel()
err = _clamp_tg_text(f"Error:\n{e}") if error is not None:
err = _clamp_tg_text(f"Error:\n{error}")
logger.debug("[error] send reply_to=%s text=%s", user_msg_id, err) logger.debug("[error] send reply_to=%s text=%s", user_msg_id, err)
await _send_or_edit_markdown( await _send_or_edit_markdown(
cfg.bot, cfg.bot,
@@ -601,16 +672,11 @@ async def handle_message(
limit=TELEGRAM_MARKDOWN_LIMIT, limit=TELEGRAM_MARKDOWN_LIMIT,
) )
return return
finally:
if running_tasks is not None:
for sid, task in list(running_tasks.items()):
if task is exec_task:
running_tasks.pop(sid, None)
await edits.shutdown()
elapsed = clock() - started_at elapsed = clock() - started_at
if cancelled: if cancelled:
if session_id is None:
session_id = progress_renderer.resume_session or resume_session
logger.info( logger.info(
"[handle] cancelled session_id=%s elapsed=%.1fs", session_id, elapsed "[handle] cancelled session_id=%s elapsed=%.1fs", session_id, elapsed
) )
@@ -627,6 +693,9 @@ async def handle_message(
) )
return return
if session_id is None or answer is None or saw_agent_message is None:
raise RuntimeError("codex exec finished without a result")
status = "done" if saw_agent_message else "error" status = "done" if saw_agent_message else "error"
progress_renderer.resume_session = session_id progress_renderer.resume_session = session_id
final_md = progress_renderer.render_final(elapsed, answer, status=status) final_md = progress_renderer.render_final(elapsed, answer, status=status)
@@ -679,7 +748,7 @@ async def poll_updates(cfg: BridgeConfig):
) )
if updates is None: if updates is None:
logger.info("[loop] getUpdates failed") logger.info("[loop] getUpdates failed")
await asyncio.sleep(2) await anyio.sleep(2)
continue continue
logger.debug("[loop] updates: %s", updates) logger.debug("[loop] updates: %s", updates)
@@ -696,7 +765,7 @@ async def poll_updates(cfg: BridgeConfig):
async def _handle_cancel( async def _handle_cancel(
cfg: BridgeConfig, cfg: BridgeConfig,
msg: dict[str, Any], msg: dict[str, Any],
running_tasks: dict[str, asyncio.Task[Any]], running_tasks: dict[int, RunningTask],
) -> None: ) -> None:
chat_id = msg["chat"]["id"] chat_id = msg["chat"]["id"]
user_msg_id = msg["message_id"] user_msg_id = msg["message_id"]
@@ -710,8 +779,8 @@ async def _handle_cancel(
) )
return return
session_id = extract_session_id(reply.get("text")) progress_id = reply.get("message_id")
if not session_id: if progress_id is None:
await cfg.bot.send_message( await cfg.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
text="nothing is currently running for that message.", text="nothing is currently running for that message.",
@@ -719,8 +788,8 @@ async def _handle_cancel(
) )
return return
task = running_tasks.get(session_id) running_task = running_tasks.get(int(progress_id))
if not task or task.done(): if running_task is None:
await cfg.bot.send_message( await cfg.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
text="nothing is currently running for that message.", text="nothing is currently running for that message.",
@@ -728,20 +797,20 @@ async def _handle_cancel(
) )
return return
logger.info("[cancel] cancelling session_id=%s", session_id) logger.info("[cancel] cancelling progress_message_id=%s", progress_id)
task.cancel() running_task.scope.cancel()
async def _run_main_loop(cfg: BridgeConfig) -> None: async def _run_main_loop(cfg: BridgeConfig) -> None:
worker_count = max(1, min(cfg.max_concurrency, 16)) worker_count = max(1, min(cfg.max_concurrency, 16))
queue: asyncio.Queue[tuple[int, int, str, str | None]] = asyncio.Queue( send_stream, receive_stream = anyio.create_memory_object_stream(
maxsize=worker_count * 2 max_buffer_size=worker_count * 2
) )
running_tasks: dict[str, asyncio.Task[Any]] = {} running_tasks: dict[int, RunningTask] = {}
async def worker() -> None: async def worker() -> None:
while True: while True:
chat_id, user_msg_id, text, resume_session = await queue.get() chat_id, user_msg_id, text, resume_session = await receive_stream.receive()
try: try:
await handle_message( await handle_message(
cfg, cfg,
@@ -753,26 +822,28 @@ async def _run_main_loop(cfg: BridgeConfig) -> None:
) )
except Exception: except Exception:
logger.exception("[handle] worker failed") logger.exception("[handle] worker failed")
finally:
queue.task_done()
try: try:
async with asyncio.TaskGroup() as tg: async with anyio.create_task_group() as tg:
for _ in range(worker_count): for _ in range(worker_count):
tg.create_task(worker()) tg.start_soon(worker)
async for msg in poll_updates(cfg): async for msg in poll_updates(cfg):
text = msg["text"] text = msg["text"]
user_msg_id = msg["message_id"] user_msg_id = msg["message_id"]
if text == "/cancel": if text == "/cancel":
tg.create_task(_handle_cancel(cfg, msg, running_tasks)) tg.start_soon(_handle_cancel, cfg, msg, running_tasks)
continue continue
r = msg.get("reply_to_message") or {} r = msg.get("reply_to_message") or {}
resume_session = resolve_resume_session(text, r.get("text")) resume_session = resolve_resume_session(text, r.get("text"))
await queue.put((msg["chat"]["id"], user_msg_id, text, resume_session)) await send_stream.send(
(msg["chat"]["id"], user_msg_id, text, resume_session)
)
finally: finally:
await send_stream.aclose()
await receive_stream.aclose()
await cfg.bot.close() await cfg.bot.close()
@@ -813,7 +884,7 @@ def run(
except ConfigError as e: except ConfigError as e:
typer.echo(str(e), err=True) typer.echo(str(e), err=True)
raise typer.Exit(code=1) raise typer.Exit(code=1)
asyncio.run(_run_main_loop(cfg)) anyio.run(_run_main_loop, cfg)
def main() -> None: def main() -> None:
+7
View File
@@ -1,4 +1,11 @@
import sys import sys
from pathlib import Path from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
+154 -113
View File
@@ -1,5 +1,4 @@
import asyncio import anyio
import pytest import pytest
from takopi.exec_bridge import ( from takopi.exec_bridge import (
@@ -187,7 +186,7 @@ class _FakeClock:
def __init__(self, start: float = 0.0) -> None: def __init__(self, start: float = 0.0) -> None:
self._now = start self._now = start
self._sleep_until: float | None = None self._sleep_until: float | None = None
self._sleep_event: asyncio.Event | None = None self._sleep_event: anyio.Event | None = None
self.sleep_calls = 0 self.sleep_calls = 0
def __call__(self) -> float: def __call__(self) -> float:
@@ -205,10 +204,10 @@ class _FakeClock:
async def sleep(self, delay: float) -> None: async def sleep(self, delay: float) -> None:
self.sleep_calls += 1 self.sleep_calls += 1
if delay <= 0: if delay <= 0:
await asyncio.sleep(0) await anyio.sleep(0)
return return
self._sleep_until = self._now + delay self._sleep_until = self._now + delay
self._sleep_event = asyncio.Event() self._sleep_event = anyio.Event()
await self._sleep_event.wait() await self._sleep_event.wait()
@@ -222,7 +221,7 @@ class _FakeRunnerWithEvents:
answer: str = "ok", answer: str = "ok",
session_id: str = "019b66fc-64c2-7a71-81cd-081c504cfeb2", session_id: str = "019b66fc-64c2-7a71-81cd-081c504cfeb2",
advance_after: float | None = None, advance_after: float | None = None,
hold: asyncio.Event | None = None, hold: anyio.Event | None = None,
) -> None: ) -> None:
self._events = events self._events = events
self._times = times self._times = times
@@ -238,16 +237,17 @@ class _FakeRunnerWithEvents:
for when, event in zip(self._times, self._events, strict=False): for when, event in zip(self._times, self._events, strict=False):
self._clock.set(when) self._clock.set(when)
await on_event(event) await on_event(event)
await asyncio.sleep(0) await anyio.sleep(0)
if self._advance_after is not None: if self._advance_after is not None:
self._clock.set(self._advance_after) self._clock.set(self._advance_after)
await asyncio.sleep(0) await anyio.sleep(0)
if self._hold is not None: if self._hold is not None:
await self._hold.wait() await self._hold.wait()
return (self._session_id, self._answer, True) return (self._session_id, self._answer, True)
def test_final_notify_sends_loud_final_message() -> None: @pytest.mark.anyio
async def test_final_notify_sends_loud_final_message() -> None:
from takopi.exec_bridge import BridgeConfig, handle_message from takopi.exec_bridge import BridgeConfig, handle_message
bot = _FakeBot() bot = _FakeBot()
@@ -261,22 +261,21 @@ def test_final_notify_sends_loud_final_message() -> None:
max_concurrency=1, max_concurrency=1,
) )
asyncio.run( await handle_message(
handle_message(
cfg, cfg,
chat_id=123, chat_id=123,
user_msg_id=10, user_msg_id=10,
text="hi", text="hi",
resume_session=None, resume_session=None,
) )
)
assert len(bot.send_calls) == 2 assert len(bot.send_calls) == 2
assert bot.send_calls[0]["disable_notification"] is True assert bot.send_calls[0]["disable_notification"] is True
assert bot.send_calls[1]["disable_notification"] is False assert bot.send_calls[1]["disable_notification"] is False
def test_new_final_message_forces_notification_when_too_long_to_edit() -> None: @pytest.mark.anyio
async def test_new_final_message_forces_notification_when_too_long_to_edit() -> None:
from takopi.exec_bridge import BridgeConfig, handle_message from takopi.exec_bridge import BridgeConfig, handle_message
bot = _FakeBot() bot = _FakeBot()
@@ -290,22 +289,21 @@ def test_new_final_message_forces_notification_when_too_long_to_edit() -> None:
max_concurrency=1, max_concurrency=1,
) )
asyncio.run( await handle_message(
handle_message(
cfg, cfg,
chat_id=123, chat_id=123,
user_msg_id=10, user_msg_id=10,
text="hi", text="hi",
resume_session=None, resume_session=None,
) )
)
assert len(bot.send_calls) == 2 assert len(bot.send_calls) == 2
assert bot.send_calls[0]["disable_notification"] is True assert bot.send_calls[0]["disable_notification"] is True
assert bot.send_calls[1]["disable_notification"] is False assert bot.send_calls[1]["disable_notification"] is False
def test_progress_edits_are_rate_limited() -> None: @pytest.mark.anyio
async def test_progress_edits_are_rate_limited() -> None:
from takopi.exec_bridge import BridgeConfig, handle_message from takopi.exec_bridge import BridgeConfig, handle_message
bot = _FakeBot() bot = _FakeBot()
@@ -345,8 +343,7 @@ def test_progress_edits_are_rate_limited() -> None:
max_concurrency=1, max_concurrency=1,
) )
asyncio.run( await handle_message(
handle_message(
cfg, cfg,
chat_id=123, chat_id=123,
user_msg_id=10, user_msg_id=10,
@@ -356,18 +353,18 @@ def test_progress_edits_are_rate_limited() -> None:
sleep=clock.sleep, sleep=clock.sleep,
progress_edit_every=1.0, progress_edit_every=1.0,
) )
)
assert len(bot.edit_calls) == 1 assert len(bot.edit_calls) == 1
assert "echo 2" in bot.edit_calls[0]["text"] assert "echo 2" in bot.edit_calls[0]["text"]
def test_progress_edits_do_not_sleep_again_without_new_events() -> None: @pytest.mark.anyio
async def test_progress_edits_do_not_sleep_again_without_new_events() -> None:
from takopi.exec_bridge import BridgeConfig, handle_message from takopi.exec_bridge import BridgeConfig, handle_message
bot = _FakeBot() bot = _FakeBot()
clock = _FakeClock() clock = _FakeClock()
hold = asyncio.Event() hold = anyio.Event()
events = [ events = [
{ {
"type": "item.started", "type": "item.started",
@@ -404,9 +401,8 @@ def test_progress_edits_do_not_sleep_again_without_new_events() -> None:
max_concurrency=1, max_concurrency=1,
) )
async def run_test() -> None: async def run_handle_message() -> None:
task = asyncio.create_task( await handle_message(
handle_message(
cfg, cfg,
chat_id=123, chat_id=123,
user_msg_id=10, user_msg_id=10,
@@ -416,12 +412,14 @@ def test_progress_edits_do_not_sleep_again_without_new_events() -> None:
sleep=clock.sleep, sleep=clock.sleep,
progress_edit_every=1.0, progress_edit_every=1.0,
) )
)
async with anyio.create_task_group() as tg:
tg.start_soon(run_handle_message)
for _ in range(100): for _ in range(100):
if clock._sleep_until is not None: if clock._sleep_until is not None:
break break
await asyncio.sleep(0) await anyio.sleep(0)
assert clock._sleep_until == pytest.approx(1.0) assert clock._sleep_until == pytest.approx(1.0)
@@ -430,23 +428,21 @@ def test_progress_edits_do_not_sleep_again_without_new_events() -> None:
for _ in range(100): for _ in range(100):
if bot.edit_calls: if bot.edit_calls:
break break
await asyncio.sleep(0) await anyio.sleep(0)
assert len(bot.edit_calls) == 1 assert len(bot.edit_calls) == 1
for _ in range(5): for _ in range(5):
await asyncio.sleep(0) await anyio.sleep(0)
assert clock.sleep_calls == 1 assert clock.sleep_calls == 1
assert clock._sleep_until is None assert clock._sleep_until is None
hold.set() hold.set()
await task
asyncio.run(run_test())
def test_bridge_flow_sends_progress_edits_and_final_resume() -> None: @pytest.mark.anyio
async def test_bridge_flow_sends_progress_edits_and_final_resume() -> None:
from takopi.exec_bridge import BridgeConfig, handle_message from takopi.exec_bridge import BridgeConfig, handle_message
bot = _FakeBot() bot = _FakeBot()
@@ -489,8 +485,7 @@ def test_bridge_flow_sends_progress_edits_and_final_resume() -> None:
max_concurrency=1, max_concurrency=1,
) )
asyncio.run( await handle_message(
handle_message(
cfg, cfg,
chat_id=123, chat_id=123,
user_msg_id=42, user_msg_id=42,
@@ -500,7 +495,6 @@ def test_bridge_flow_sends_progress_edits_and_final_resume() -> None:
sleep=clock.sleep, sleep=clock.sleep,
progress_edit_every=1.0, progress_edit_every=1.0,
) )
)
assert bot.send_calls[0]["reply_to_message_id"] == 42 assert bot.send_calls[0]["reply_to_message_id"] == 42
assert "working" in bot.send_calls[0]["text"] assert "working" in bot.send_calls[0]["text"]
@@ -510,7 +504,8 @@ def test_bridge_flow_sends_progress_edits_and_final_resume() -> None:
assert len(bot.delete_calls) == 1 assert len(bot.delete_calls) == 1
def test_handle_cancel_without_reply_prompts_user() -> None: @pytest.mark.anyio
async def test_handle_cancel_without_reply_prompts_user() -> None:
from takopi.exec_bridge import BridgeConfig, _handle_cancel from takopi.exec_bridge import BridgeConfig, _handle_cancel
bot = _FakeBot() bot = _FakeBot()
@@ -526,13 +521,14 @@ def test_handle_cancel_without_reply_prompts_user() -> None:
msg = {"chat": {"id": 123}, "message_id": 10} msg = {"chat": {"id": 123}, "message_id": 10}
running_tasks: dict = {} running_tasks: dict = {}
asyncio.run(_handle_cancel(cfg, msg, running_tasks)) await _handle_cancel(cfg, msg, running_tasks)
assert len(bot.send_calls) == 1 assert len(bot.send_calls) == 1
assert "reply to the progress message" in bot.send_calls[0]["text"] assert "reply to the progress message" in bot.send_calls[0]["text"]
def test_handle_cancel_with_no_session_id_says_nothing_running() -> None: @pytest.mark.anyio
async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> None:
from takopi.exec_bridge import BridgeConfig, _handle_cancel from takopi.exec_bridge import BridgeConfig, _handle_cancel
bot = _FakeBot() bot = _FakeBot()
@@ -548,79 +544,122 @@ def test_handle_cancel_with_no_session_id_says_nothing_running() -> None:
msg = { msg = {
"chat": {"id": 123}, "chat": {"id": 123},
"message_id": 10, "message_id": 10,
"reply_to_message": {"text": "no uuid here"}, "reply_to_message": {"text": "no message id"},
} }
running_tasks: dict = {} running_tasks: dict = {}
asyncio.run(_handle_cancel(cfg, msg, running_tasks))
assert len(bot.send_calls) == 1
assert "nothing is currently running" in bot.send_calls[0]["text"]
def test_handle_cancel_with_finished_task_says_nothing_running() -> None:
from takopi.exec_bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _FakeRunner(answer="ok")
cfg = BridgeConfig(
bot=bot, # type: ignore[arg-type]
runner=runner, # type: ignore[arg-type]
chat_id=123,
final_notify=True,
startup_msg="",
max_concurrency=1,
)
session_id = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"text": f"resume: `{session_id}`"},
}
running_tasks: dict = {} # Session not in running_tasks
asyncio.run(_handle_cancel(cfg, msg, running_tasks))
assert len(bot.send_calls) == 1
assert "nothing is currently running" in bot.send_calls[0]["text"]
def test_handle_cancel_cancels_running_task() -> None:
from takopi.exec_bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _FakeRunner(answer="ok")
cfg = BridgeConfig(
bot=bot, # type: ignore[arg-type]
runner=runner, # type: ignore[arg-type]
chat_id=123,
final_notify=True,
startup_msg="",
max_concurrency=1,
)
session_id = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"text": f"resume: `{session_id}`"},
}
async def run_test():
task = asyncio.create_task(asyncio.sleep(10))
running_tasks = {session_id: task}
await _handle_cancel(cfg, msg, running_tasks) await _handle_cancel(cfg, msg, running_tasks)
assert len(bot.send_calls) == 1
assert "nothing is currently running" in bot.send_calls[0]["text"]
@pytest.mark.anyio
async def test_handle_cancel_with_finished_task_says_nothing_running() -> None:
from takopi.exec_bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _FakeRunner(answer="ok")
cfg = BridgeConfig(
bot=bot, # type: ignore[arg-type]
runner=runner, # type: ignore[arg-type]
chat_id=123,
final_notify=True,
startup_msg="",
max_concurrency=1,
)
progress_id = 99
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": progress_id},
}
running_tasks: dict = {} # Progress message not in running_tasks
await _handle_cancel(cfg, msg, running_tasks)
assert len(bot.send_calls) == 1
assert "nothing is currently running" in bot.send_calls[0]["text"]
@pytest.mark.anyio
async def test_handle_cancel_cancels_running_task() -> None:
from takopi.exec_bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _FakeRunner(answer="ok")
cfg = BridgeConfig(
bot=bot, # type: ignore[arg-type]
runner=runner, # type: ignore[arg-type]
chat_id=123,
final_notify=True,
startup_msg="",
max_concurrency=1,
)
progress_id = 42
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": progress_id},
}
from takopi.exec_bridge import RunningTask
cancelled_event = anyio.Event()
cancel_scope = anyio.CancelScope()
running_task = RunningTask(scope=cancel_scope)
async def sleeper() -> None:
with cancel_scope:
try: try:
await task await anyio.sleep(10)
except asyncio.CancelledError: except anyio.get_cancelled_exc_class():
return True cancelled_event.set()
return False return
cancelled = asyncio.run(run_test()) async with anyio.create_task_group() as tg:
tg.start_soon(sleeper)
running_tasks = {progress_id: running_task}
await _handle_cancel(cfg, msg, running_tasks)
await cancelled_event.wait()
assert cancelled is True
assert len(bot.send_calls) == 0 # No error message sent assert len(bot.send_calls) == 0 # No error message sent
@pytest.mark.anyio
async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
from takopi.exec_bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _FakeRunner(answer="ok")
cfg = BridgeConfig(
bot=bot, # type: ignore[arg-type]
runner=runner, # type: ignore[arg-type]
chat_id=123,
final_notify=True,
startup_msg="",
max_concurrency=1,
)
from takopi.exec_bridge import RunningTask
scope_first = anyio.CancelScope()
scope_second = anyio.CancelScope()
task_first = RunningTask(scope=scope_first)
task_second = RunningTask(scope=scope_second)
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": 1},
}
running_tasks = {1: task_first, 2: task_second}
await _handle_cancel(cfg, msg, running_tasks)
assert scope_first.cancel_called is True
assert scope_second.cancel_called is False
assert len(bot.send_calls) == 0
class _FakeRunnerCancellable: class _FakeRunnerCancellable:
def __init__(self, session_id: str = "019b66fc-64c2-7a71-81cd-081c504cfeb2"): def __init__(self, session_id: str = "019b66fc-64c2-7a71-81cd-081c504cfeb2"):
self._session_id = session_id self._session_id = session_id
@@ -629,11 +668,12 @@ class _FakeRunnerCancellable:
on_event = kwargs.get("on_event") on_event = kwargs.get("on_event")
if on_event: if on_event:
await on_event({"type": "thread.started", "thread_id": self._session_id}) await on_event({"type": "thread.started", "thread_id": self._session_id})
await asyncio.sleep(10) # Will be cancelled await anyio.sleep(10) # Will be cancelled
return (self._session_id, "ok", True) return (self._session_id, "ok", True)
def test_handle_message_cancelled_renders_cancelled_state() -> None: @pytest.mark.anyio
async def test_handle_message_cancelled_renders_cancelled_state() -> None:
from takopi.exec_bridge import BridgeConfig, handle_message from takopi.exec_bridge import BridgeConfig, handle_message
bot = _FakeBot() bot = _FakeBot()
@@ -649,9 +689,8 @@ def test_handle_message_cancelled_renders_cancelled_state() -> None:
) )
running_tasks: dict = {} running_tasks: dict = {}
async def run_test(): async def run_handle_message() -> None:
task = asyncio.create_task( await handle_message(
handle_message(
cfg, cfg,
chat_id=123, chat_id=123,
user_msg_id=10, user_msg_id=10,
@@ -659,13 +698,15 @@ def test_handle_message_cancelled_renders_cancelled_state() -> None:
resume_session=None, resume_session=None,
running_tasks=running_tasks, running_tasks=running_tasks,
) )
)
await asyncio.sleep(0.01) # Let task start and register
assert session_id in running_tasks
running_tasks[session_id].cancel()
await task
asyncio.run(run_test()) async with anyio.create_task_group() as tg:
tg.start_soon(run_handle_message)
for _ in range(100):
if running_tasks:
break
await anyio.sleep(0)
assert running_tasks
running_tasks[next(iter(running_tasks))].scope.cancel()
assert len(bot.send_calls) == 1 # Progress message assert len(bot.send_calls) == 1 # Progress message
assert len(bot.edit_calls) >= 1 assert len(bot.edit_calls) >= 1
+68 -11
View File
@@ -1,11 +1,13 @@
import asyncio import anyio
import pytest
from takopi.exec_bridge import CodexExecRunner from takopi.exec_bridge import CodexExecRunner, EventCallback
def test_run_serialized_serializes_same_session() -> None: @pytest.mark.anyio
async def test_run_serialized_serializes_same_session() -> None:
runner = CodexExecRunner(codex_cmd="codex", extra_args=[]) runner = CodexExecRunner(codex_cmd="codex", extra_args=[])
gate = asyncio.Event() gate = anyio.Event()
in_flight = 0 in_flight = 0
max_in_flight = 0 max_in_flight = 0
@@ -19,13 +21,68 @@ def test_run_serialized_serializes_same_session() -> None:
runner.run = run_stub # type: ignore[assignment] runner.run = run_stub # type: ignore[assignment]
async def run_test() -> None: async with anyio.create_task_group() as tg:
t1 = asyncio.create_task(runner.run_serialized("a", "sid")) tg.start_soon(runner.run_serialized, "a", "sid")
t2 = asyncio.create_task(runner.run_serialized("b", "sid")) tg.start_soon(runner.run_serialized, "b", "sid")
await asyncio.sleep(0) await anyio.sleep(0)
gate.set() gate.set()
await asyncio.gather(t1, t2)
asyncio.run(run_test())
assert max_in_flight == 1 assert max_in_flight == 1
@pytest.mark.anyio
async def test_run_serialized_allows_parallel_new_sessions() -> None:
runner = CodexExecRunner(codex_cmd="codex", extra_args=[])
gate = anyio.Event()
in_flight = 0
max_in_flight = 0
async def run_stub(*_args, **_kwargs):
nonlocal in_flight, max_in_flight
in_flight += 1
max_in_flight = max(max_in_flight, in_flight)
await gate.wait()
in_flight -= 1
return ("sid", "ok", True)
runner.run = run_stub # type: ignore[assignment]
async with anyio.create_task_group() as tg:
tg.start_soon(runner.run_serialized, "a", None)
tg.start_soon(runner.run_serialized, "b", None)
with anyio.move_on_after(1):
while max_in_flight < 2:
await anyio.sleep(0)
gate.set()
assert max_in_flight == 2
@pytest.mark.anyio
async def test_new_session_holds_lock_for_resumes() -> None:
runner = CodexExecRunner(codex_cmd="codex", extra_args=[])
finish = anyio.Event()
resume_started = anyio.Event()
async def run_stub(
_prompt: str,
session_id: str | None,
on_event: EventCallback | None = None,
) -> tuple[str, str, bool]:
if session_id is None:
if on_event:
await on_event({"type": "thread.started", "thread_id": "sid"})
await finish.wait()
return ("sid", "ok", True)
resume_started.set()
return ("sid", "ok", True)
runner.run = run_stub # type: ignore[assignment]
async with anyio.create_task_group() as tg:
tg.start_soon(runner.run_serialized, "first", None)
await anyio.sleep(0)
tg.start_soon(runner.run_serialized, "resume", "sid")
await anyio.sleep(0)
assert not resume_started.is_set()
finish.set()
+7 -17
View File
@@ -1,29 +1,19 @@
import asyncio
import sys import sys
import pytest
from takopi import exec_bridge from takopi import exec_bridge
def test_manage_subprocess_kills_when_terminate_times_out(monkeypatch) -> None: @pytest.mark.anyio
async def fake_wait_for(awaitable, *args, **kwargs): async def test_manage_subprocess_kills_when_terminate_times_out() -> None:
if hasattr(awaitable, "close"):
awaitable.close()
elif hasattr(awaitable, "cancel"):
awaitable.cancel()
raise asyncio.TimeoutError
monkeypatch.setattr(exec_bridge.asyncio, "wait_for", fake_wait_for)
async def run() -> int | None:
async with exec_bridge.manage_subprocess( async with exec_bridge.manage_subprocess(
sys.executable, sys.executable,
"-c", "-c",
"import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(10)", "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(10)",
terminate_timeout=0.01,
) as proc: ) as proc:
assert proc.returncode is None assert proc.returncode is None
return proc.returncode
rc = asyncio.run(run()) assert proc.returncode is not None
assert proc.returncode != 0
assert rc is not None
assert rc != 0
+8 -11
View File
@@ -1,4 +1,3 @@
import asyncio
import logging import logging
import httpx import httpx
@@ -8,7 +7,8 @@ from takopi.logging import RedactTokenFilter
from takopi.telegram import TelegramClient from takopi.telegram import TelegramClient
def test_telegram_429_no_retry() -> None: @pytest.mark.anyio
async def test_telegram_429_no_retry() -> None:
calls: list[int] = [] calls: list[int] = []
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
@@ -25,21 +25,21 @@ def test_telegram_429_no_retry() -> None:
transport = httpx.MockTransport(handler) transport = httpx.MockTransport(handler)
async def run() -> dict | None:
client = httpx.AsyncClient(transport=transport) client = httpx.AsyncClient(transport=transport)
try: try:
tg = TelegramClient("123:abcDEF_ghij", client=client) tg = TelegramClient("123:abcDEF_ghij", client=client)
return await tg._post("sendMessage", {"chat_id": 1, "text": "hi"}) result = await tg._post("sendMessage", {"chat_id": 1, "text": "hi"})
finally: finally:
await client.aclose() await client.aclose()
result = asyncio.run(run())
assert result is None assert result is None
assert len(calls) == 1 assert len(calls) == 1
def test_no_token_in_logs_on_http_error(caplog: pytest.LogCaptureFixture) -> None: @pytest.mark.anyio
async def test_no_token_in_logs_on_http_error(
caplog: pytest.LogCaptureFixture,
) -> None:
token = "123:abcDEF_ghij" token = "123:abcDEF_ghij"
redactor = RedactTokenFilter() redactor = RedactTokenFilter()
root_logger = logging.getLogger() root_logger = logging.getLogger()
@@ -50,7 +50,7 @@ def test_no_token_in_logs_on_http_error(caplog: pytest.LogCaptureFixture) -> Non
transport = httpx.MockTransport(handler) transport = httpx.MockTransport(handler)
async def run() -> None: caplog.set_level(logging.ERROR)
client = httpx.AsyncClient(transport=transport) client = httpx.AsyncClient(transport=transport)
try: try:
tg = TelegramClient(token, client=client) tg = TelegramClient(token, client=client)
@@ -58,9 +58,6 @@ def test_no_token_in_logs_on_http_error(caplog: pytest.LogCaptureFixture) -> Non
finally: finally:
await client.aclose() await client.aclose()
caplog.set_level(logging.ERROR)
asyncio.run(run())
root_logger.removeFilter(redactor) root_logger.removeFilter(redactor)
assert token not in caplog.text assert token not in caplog.text
Generated
+17
View File
@@ -331,6 +331,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
] ]
[[package]]
name = "pytest-anyio"
version = "0.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/44/a02e5877a671b0940f21a7a0d9704c22097b123ed5cdbcca9cab39f17acc/pytest-anyio-0.0.0.tar.gz", hash = "sha256:b41234e9e9ad7ea1dbfefcc1d6891b23d5ef7c9f07ccf804c13a9cc338571fd3", size = 1560, upload-time = "2021-06-29T22:57:30.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/25/bd6493ae85d0a281b6a0f248d0fdb1d9aa2b31f18bcd4a8800cf397d8209/pytest_anyio-0.0.0-py2.py3-none-any.whl", hash = "sha256:dc8b5c4741cb16ff90be37fddd585ca943ed12bbeb563de7ace6cd94441d8746", size = 1999, upload-time = "2021-06-29T22:57:29.158Z" },
]
[[package]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "7.0.0" version = "7.0.0"
@@ -420,6 +433,7 @@ name = "takopi"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "anyio" },
{ name = "httpx" }, { name = "httpx" },
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "rich" }, { name = "rich" },
@@ -430,6 +444,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-anyio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "ty" }, { name = "ty" },
@@ -437,6 +452,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "anyio", specifier = ">=4.12.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "rich", specifier = ">=14.2.0" }, { name = "rich", specifier = ">=14.2.0" },
@@ -447,6 +463,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-anyio", specifier = ">=0.0.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.14.10" }, { name = "ruff", specifier = ">=0.14.10" },
{ name = "ty", specifier = ">=0.0.8" }, { name = "ty", specifier = ">=0.0.8" },