diff options
| author | blasty <blasty@local> | 2026-07-09 11:22:18 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 11:22:18 +0200 |
| commit | 90636755b26c5dbee8acdda5e37c7aff9e896854 (patch) | |
| tree | 7aa0176977291608e06ce19bd35058f40da6d3e5 | |
| parent | persistent MCP client: warm handshake, keep-alive pool, error taxonomy, threa... (diff) | |
| download | ida-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.py | 58 | ||||
| -rwxr-xr-x | tests/serverctl.sh | 65 | ||||
| -rw-r--r-- | tests/stress_client.py | 290 | ||||
| -rw-r--r-- | uv.lock | 191 |
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:])) @@ -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" }, +] |
