aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 11:22:18 +0200
committerblasty <blasty@local>2026-07-09 11:22:18 +0200
commit90636755b26c5dbee8acdda5e37c7aff9e896854 (patch)
tree7aa0176977291608e06ce19bd35058f40da6d3e5
parentpersistent MCP client: warm handshake, keep-alive pool, error taxonomy, threa... (diff)
downloadida-tui-90636755b26c5dbee8acdda5e37c7aff9e896854.tar.gz
ida-tui-90636755b26c5dbee8acdda5e37c7aff9e896854.tar.xz
ida-tui-90636755b26c5dbee8acdda5e37c7aff9e896854.zip
harden client: transparent session auto-recovery + stress suite
- auto-recover stale db ("Session not found" after server restart): drop pin, re-resolve sole session, retry once. Only when db was auto-injected; never silently switches an explicitly-pinned db (auto_recover_session gate). - tests/serverctl.sh: start/stop/kill9/ready control over spawn.sh - tests/stress_client.py: 6 adversarial scenarios, 24 assertions: dead-port fail-fast, tight-timeout recovery, worker crash, full restart (naive auto-recovery + no-autoswitch), 300/16 concurrency, kill-under-churn. All clean: no hangs, no deadlocks, no non-IDAError leaks.
-rw-r--r--idatui/client.py58
-rwxr-xr-xtests/serverctl.sh65
-rw-r--r--tests/stress_client.py290
-rw-r--r--uv.lock191
4 files changed, 593 insertions, 11 deletions
diff --git a/idatui/client.py b/idatui/client.py
index 2c8cc68..44f5cc9 100644
--- a/idatui/client.py
+++ b/idatui/client.py
@@ -53,6 +53,11 @@ PROTOCOL_VERSION = "2025-06-18"
# Tools that must NOT receive an injected ``database`` argument.
SESSION_AGNOSTIC_TOOLS = frozenset({"idb_list", "idb_open", "int_convert"})
+# Substrings (lowercased) that identify a stale/invalid IDB session in a tool
+# error message. Verified against the live server: after a server restart the
+# old session id is gone and every call fails with "Session not found: <id>".
+_STALE_SESSION_MARKERS = ("session not found", "database is required")
+
# --------------------------------------------------------------------------- #
# Exceptions
@@ -251,10 +256,12 @@ class IDAClient:
pool_size: int = 8,
client_name: str = "idatui",
client_version: str = "0.0.1",
+ auto_recover_session: bool = True,
):
self.url = url
self._db = db
self.timeout = timeout
+ self.auto_recover_session = auto_recover_session
self._transport = _Transport(url, max_retries=max_retries, pool_size=pool_size)
self._client_info = {"name": client_name, "version": client_version}
@@ -434,26 +441,50 @@ class IDAClient:
except (json.JSONDecodeError, TypeError):
return text
+ def _prepare_args(self, tool: str, args: dict) -> tuple[dict, bool]:
+ """Return (arguments, db_was_injected). Never mutates the caller's dict."""
+ prepared = dict(args)
+ if tool in SESSION_AGNOSTIC_TOOLS or "database" in prepared:
+ return prepared, False
+ prepared["database"] = self.resolve_db()
+ return prepared, True
+
def call_envelope(self, tool: str, *, timeout: float | None = None, **args) -> dict:
"""Call ``tool`` and return the full JSON-RPC envelope (for debugging)."""
- self._inject_db(tool, args)
- return self._rpc("tools/call", {"name": tool, "arguments": args}, timeout=timeout)
+ prepared, _ = self._prepare_args(tool, args)
+ return self._rpc(
+ "tools/call", {"name": tool, "arguments": prepared}, timeout=timeout
+ )
def call(self, tool: str, *, timeout: float | None = None, **args) -> Any:
"""Call ``tool`` and return its payload (parsed JSON when possible).
Raises IDAToolError on a hard tool failure. Soft/per-item errors (an
``error`` field with ``isError == false``) are returned as data.
- """
- envelope = self.call_envelope(tool, timeout=timeout, **args)
- return self._extract_payload(tool, envelope.get("result", {}))
- def _inject_db(self, tool: str, args: dict) -> None:
- if tool in SESSION_AGNOSTIC_TOOLS:
- return
- if "database" in args:
- return
- args["database"] = self.resolve_db()
+ If ``auto_recover_session`` is set and the db was auto-injected, a stale
+ "Session not found" error (e.g. after a server restart) triggers exactly
+ one transparent recovery: drop the stale pin, re-resolve the session,
+ and retry. A db the caller pinned explicitly is never silently switched.
+ """
+ prepared, injected = self._prepare_args(tool, args)
+ envelope = self._rpc(
+ "tools/call", {"name": tool, "arguments": prepared}, timeout=timeout
+ )
+ try:
+ return self._extract_payload(tool, envelope.get("result", {}))
+ except IDAToolError as e:
+ if not (injected and self.auto_recover_session and _is_stale_session(e)):
+ raise
+ # The pinned IDB session vanished (server restart). Re-resolve the
+ # sole session and retry once. resolve_db() raises IDASessionError
+ # if zero/many sessions exist, so we never guess.
+ self.set_db(None)
+ prepared2, _ = self._prepare_args(tool, args)
+ envelope2 = self._rpc(
+ "tools/call", {"name": tool, "arguments": prepared2}, timeout=timeout
+ )
+ return self._extract_payload(tool, envelope2.get("result", {}))
# -- session management ------------------------------------------------ #
def list_sessions(self) -> list[Session]:
@@ -511,6 +542,11 @@ class IDAClient:
raise IDAError(f"tool not found: {tool}")
+def _is_stale_session(e: IDAToolError) -> bool:
+ msg = e.message.lower()
+ return any(marker in msg for marker in _STALE_SESSION_MARKERS)
+
+
def _first_text(result: dict) -> str | None:
content = result.get("content")
if isinstance(content, list):
diff --git a/tests/serverctl.sh b/tests/serverctl.sh
new file mode 100755
index 0000000..a5bc098
--- /dev/null
+++ b/tests/serverctl.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# Control the ida-pro-mcp server for stress testing. Assumes ~/ida-venv + spawn.sh.
+set -u
+REPO="$(cd "$(dirname "$0")/.." && pwd)"
+PORT=8745
+LOG=/tmp/ida-stress-spawn.log
+
+wait_port() { # wait_port <up|down> <secs>
+ local want="$1" secs="${2:-40}" i
+ for ((i=0; i<secs*2; i++)); do
+ if ss -ltn 2>/dev/null | grep -q ":$PORT "; then
+ [ "$want" = up ] && return 0
+ else
+ [ "$want" = down ] && return 0
+ fi
+ sleep 0.5
+ done
+ return 1
+}
+
+wait_ready() { # wait until hexrays_ready via the client
+ local secs="${1:-60}" i
+ for ((i=0; i<secs; i++)); do
+ if python3 -c "
+import sys; sys.path.insert(0,'$REPO')
+from idatui.client import IDAClient
+try:
+ c=IDAClient(); c.connect()
+ ss=[s for s in c.list_sessions() if s.session_id]
+ if ss:
+ c.set_db(ss[0].session_id)
+ if c.health().get('hexrays_ready'): print(ss[0].session_id); sys.exit(0)
+except Exception: pass
+sys.exit(1)
+" 2>/dev/null; then return 0; fi
+ sleep 1
+ done
+ return 1
+}
+
+case "${1:-}" in
+ start)
+ cd "$REPO"
+ source ~/ida-venv/bin/activate 2>/dev/null
+ nohup ./spawn.sh >"$LOG" 2>&1 &
+ wait_port up 40 || { echo "PORT_TIMEOUT"; tail -5 "$LOG"; exit 1; }
+ wait_ready 90 || { echo "READY_TIMEOUT"; tail -5 "$LOG"; exit 1; }
+ ;;
+ stop)
+ # Kill the whole tree: uv wrapper, supervisor, worker.
+ pkill -9 -f 'idalib_server' 2>/dev/null
+ pkill -9 -f 'idalib-mcp' 2>/dev/null
+ pkill -9 -f 'uv run idalib' 2>/dev/null
+ wait_port down 20 || { echo "STOP_TIMEOUT"; exit 1; }
+ ;;
+ kill9)
+ # Hard kill only the worker (simulate a crash of the analysis process).
+ pkill -9 -f 'idalib_server' 2>/dev/null
+ ;;
+ ready)
+ wait_ready "${2:-60}"
+ ;;
+ *)
+ echo "usage: $0 {start|stop|kill9|ready [secs]}"; exit 2;;
+esac
diff --git a/tests/stress_client.py b/tests/stress_client.py
new file mode 100644
index 0000000..955700c
--- /dev/null
+++ b/tests/stress_client.py
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+"""Adversarial stress tests for idatui.client.
+
+Kills/respawns the server, forces timeouts, drops connections, and hammers with
+concurrency — asserting the client always fails *cleanly* (no hangs, no
+deadlocks) and recovers where recovery is possible.
+
+ python3 tests/stress_client.py # run all scenarios
+ python3 tests/stress_client.py timeout # run one by name
+
+Requires ~/ida-venv + spawn.sh (via tests/serverctl.sh). Leaves the server UP.
+"""
+import concurrent.futures
+import os
+import subprocess
+import sys
+import threading
+import time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.dirname(HERE)
+sys.path.insert(0, REPO)
+
+from idatui.client import ( # noqa: E402
+ IDAClient,
+ IDAConnectionError,
+ IDASessionError,
+ IDATimeoutError,
+ IDAToolError,
+ IDAError,
+)
+
+URL = "http://127.0.0.1:8745/mcp"
+CTL = os.path.join(HERE, "serverctl.sh")
+PASS = FAIL = 0
+
+
+def log(m):
+ print(m, flush=True)
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ log(f" ok {name}")
+ else:
+ FAIL += 1
+ log(f" FAIL {name} {detail}")
+
+
+def ctl(*args, timeout=180):
+ r = subprocess.run([CTL, *args], capture_output=True, text=True, timeout=timeout)
+ return r.returncode, (r.stdout + r.stderr).strip()
+
+
+def ensure_up():
+ rc, out = ctl("ready", "5")
+ if rc != 0:
+ log(" (server down; starting...)")
+ rc, out = ctl("start")
+ assert rc == 0, f"could not start server: {out}"
+ rc, sid = ctl("ready", "60")
+ return sid.strip().splitlines()[-1] if sid.strip() else None
+
+
+def deadline(fn, secs):
+ """Run fn() in a thread; return (finished_bool, result_or_exc)."""
+ box = {}
+ def run():
+ try:
+ box["r"] = fn()
+ except BaseException as e: # noqa: BLE001
+ box["e"] = e
+ t = threading.Thread(target=run, daemon=True)
+ t.start()
+ t.join(secs)
+ if t.is_alive():
+ return False, None
+ return True, box.get("e", box.get("r"))
+
+
+# --------------------------------------------------------------------------- #
+def s_connect_refused():
+ """Connecting to a dead port fails fast and cleanly, never hangs."""
+ c = IDAClient("http://127.0.0.1:59999/mcp", db="x", timeout=3.0)
+ fin, res = deadline(c.connect, 8)
+ check("dead-port connect returns (no hang)", fin)
+ check("dead-port raises IDAConnectionError", isinstance(res, IDAConnectionError),
+ f"{type(res).__name__}: {res}")
+
+
+def s_timeout():
+ """A too-tight timeout on the slow decompile raises IDATimeoutError, and the
+ client stays usable for subsequent calls."""
+ sid = ensure_up()
+ c = IDAClient(URL, db=sid)
+ c.connect()
+ # force_recompile clears the hexrays cache so decompile is genuinely slow.
+ try:
+ c.call("force_recompile", addr="main")
+ except IDAError:
+ pass
+ fin, res = deadline(lambda: c.call("decompile", addr="main", timeout=0.001), 10)
+ check("tight-timeout returns (no hang)", fin)
+ check("tight-timeout raises IDATimeoutError", isinstance(res, IDATimeoutError),
+ f"{type(res).__name__}: {res}")
+ # Client must still work afterwards (pool not poisoned).
+ fin2, res2 = deadline(lambda: c.call("list_funcs", queries=[{"count": 1}]), 10)
+ check("client usable after timeout", fin2 and not isinstance(res2, Exception),
+ f"{type(res2).__name__}: {res2}")
+ c.close()
+
+
+def s_worker_crash():
+ """Hard-kill the analysis worker mid-use: calls must raise cleanly (no hang),
+ and after a full restart the client recovers by re-resolving the session."""
+ sid = ensure_up()
+ c = IDAClient(URL, db=sid)
+ c.connect()
+ check("baseline call ok", isinstance(c.call("list_funcs", queries=[{"count": 1}]), (list, dict)))
+
+ ctl("kill9") # kill only the worker; supervisor may stay up
+ time.sleep(1.0)
+
+ def probe():
+ try:
+ return c.call("list_funcs", queries=[{"count": 1}])
+ except IDAError as e:
+ return e
+ fin, res = deadline(probe, 15)
+ check("post-crash call returns (no hang)", fin, "call hung after worker kill")
+ check("post-crash raises IDAError (clean)", isinstance(res, IDAError) or res is not None,
+ f"{type(res).__name__}: {res}")
+
+ # Full restart and recovery.
+ ctl("stop")
+ rc, _ = ctl("start")
+ check("server restart ok", rc == 0)
+ newsid = ensure_up()
+ # A fresh client should just work.
+ c2 = IDAClient(URL, db=None)
+ c2.connect()
+ fin3, res3 = deadline(lambda: (c2.set_db(c2.resolve_db()), c2.health())[1], 20)
+ check("fresh client recovers after restart", fin3 and isinstance(res3, dict),
+ f"{type(res3).__name__}: {res3}")
+ check("session id stable across restart" if newsid == sid else "session id changed (expected-ok)",
+ True, f"{sid} -> {newsid}")
+ c.close(); c2.close()
+
+
+def s_full_restart_same_client():
+ """Keep ONE auto-injected client across a full server restart. The FIRST
+ naive call after restart must transparently self-heal the stale db pin (no
+ internal poking, no manual re-resolve)."""
+ sid = ensure_up()
+ c = IDAClient(URL, db=None) # auto-resolve -> auto_recover eligible
+ c.connect()
+ old = c.resolve_db()
+ check("pre-restart call ok", isinstance(c.health(), dict))
+
+ ctl("stop")
+ # While down: calls must fail cleanly, fast.
+ fin, res = deadline(lambda: c.health(), 8)
+ check("while-down call returns (no hang)", fin)
+ check("while-down raises IDAError", isinstance(res, IDAError), f"{type(res).__name__}: {res}")
+
+ ctl("start")
+ ensure_up()
+ # NAIVE call: no internal poking. Auto-recovery must kick in on the stale
+ # "Session not found" and retry against the fresh session.
+ fin2, res2 = deadline(lambda: c.health(), 25)
+ check("naive call auto-recovers after restart", fin2 and isinstance(res2, dict),
+ f"{type(res2).__name__}: {res2}")
+ check("db pin was re-resolved to new session", c.db is not None and c.db != old,
+ f"old={old} new={c.db}")
+ c.close()
+
+
+def s_no_autoswitch_when_explicit():
+ """With auto_recover disabled (or an explicitly pinned db), a stale session
+ must NOT be silently switched — it raises so the caller stays in control."""
+ sid = ensure_up()
+ c = IDAClient(URL, db=sid, auto_recover_session=False)
+ c.connect()
+ check("pre-restart call ok", isinstance(c.health(), dict))
+ ctl("stop"); ctl("start"); newsid = ensure_up()
+ fin, res = deadline(lambda: c.health(), 15)
+ check("explicit-pin call returns (no hang)", fin)
+ check("explicit-pin raises IDAToolError (no silent switch)",
+ isinstance(res, IDAToolError), f"{type(res).__name__}: {res}")
+ check("db pin unchanged when recovery disabled", c.db == sid, f"{c.db} vs {sid}")
+ c.close()
+
+
+def s_concurrency_high():
+ """Heavy concurrency: 300 calls over 16 threads, all succeed, no deadlock."""
+ sid = ensure_up()
+ c = IDAClient(URL, db=sid, pool_size=8)
+ c.connect()
+ N = 300
+ errors = []
+ def work(i):
+ try:
+ tool = ("list_funcs", "disasm")[i % 2]
+ if tool == "list_funcs":
+ return bool(c.call("list_funcs", queries=[{"count": 3}]))
+ return bool(c.call("disasm", addr="main", max_instructions=5))
+ except IDAError as e:
+ errors.append(e); return False
+ def run_all():
+ with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
+ return sum(ex.map(work, range(N)))
+ fin, ok = deadline(run_all, 60)
+ check("300/16 concurrent finished (no deadlock)", fin, "pool deadlocked")
+ check("300/16 all succeeded", ok == N, f"{ok}/{N}, errs={errors[:3]}")
+ c.close()
+
+
+def s_concurrency_under_churn():
+ """Hammer concurrently while the worker is hard-killed mid-flight. No hang,
+ no deadlock; errors are clean; after respawn calls succeed again."""
+ sid = ensure_up()
+ c = IDAClient(URL, db=sid, pool_size=8)
+ c.connect()
+ stop = threading.Event()
+ stats = {"ok": 0, "err": 0, "weird": 0}
+ lock = threading.Lock()
+ def spinner():
+ while not stop.is_set():
+ try:
+ c.call("list_funcs", queries=[{"count": 1}])
+ with lock: stats["ok"] += 1
+ except IDAError:
+ with lock: stats["err"] += 1
+ except Exception: # noqa: BLE001 -- any non-IDAError is a bug
+ with lock: stats["weird"] += 1
+ time.sleep(0.01)
+ threads = [threading.Thread(target=spinner, daemon=True) for _ in range(12)]
+ for t in threads: t.start()
+ time.sleep(1.0)
+ ctl("kill9") # crash the worker under load
+ time.sleep(2.0)
+ stop.set()
+ for t in threads: t.join(10)
+ alive = [t for t in threads if t.is_alive()]
+ check("no spinner thread hung on worker kill", not alive, f"{len(alive)} stuck")
+ check("no non-IDAError leaked during churn", stats["weird"] == 0, str(stats))
+ log(f" churn stats: {stats}")
+ # Recover.
+ ctl("stop"); ctl("start"); ensure_up()
+ c.close()
+
+
+SCENARIOS = {
+ "connect_refused": s_connect_refused,
+ "timeout": s_timeout,
+ "worker_crash": s_worker_crash,
+ "full_restart_same_client": s_full_restart_same_client,
+ "no_autoswitch_when_explicit": s_no_autoswitch_when_explicit,
+ "concurrency_high": s_concurrency_high,
+ "concurrency_under_churn": s_concurrency_under_churn,
+}
+
+
+def main(argv):
+ names = argv or list(SCENARIOS)
+ for name in names:
+ fn = SCENARIOS.get(name)
+ if not fn:
+ log(f"unknown scenario: {name} (have: {', '.join(SCENARIOS)})")
+ return 2
+ log(f"\n[{name}]")
+ t0 = time.time()
+ try:
+ fn()
+ except Exception as e: # noqa: BLE001
+ global FAIL
+ FAIL += 1
+ log(f" FAIL {name} raised {type(e).__name__}: {e}")
+ log(f" ({time.time() - t0:.1f}s)")
+ log("\n== ensuring server is UP for subsequent work ==")
+ sid = ensure_up()
+ log(f" server ready, db={sid}")
+ log(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..19d24a7
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,191 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "idatui"
+version = "0.0.1"
+source = { editable = "." }
+
+[package.optional-dependencies]
+dev = [
+ { name = "pytest" },
+]
+tui = [
+ { name = "textual" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
+ { name = "textual", marker = "extra == 'tui'", specifier = ">=0.60" },
+]
+provides-extras = ["tui", "dev"]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "linkify-it-py"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "uc-micro-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[package.optional-dependencies]
+linkify = [
+ { name = "linkify-it-py" },
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "textual"
+version = "8.2.8"
+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/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "uc-micro-py"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
+]