From 995ba47e05c8a72c705404c87f862823ac1aa6c7 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Tue, 1 Sep 2026 19:14:41 +0000 Subject: [PATCH] Parse wallet output that mixes banner text with JSON and show wallet detail on failure --- strix/interface/cloud/billing.py | 44 ++++++++++++++++++++------------ tests/test_cloud_wallet.py | 15 +++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/strix/interface/cloud/billing.py b/strix/interface/cloud/billing.py index c616685e..c91a731c 100644 --- a/strix/interface/cloud/billing.py +++ b/strix/interface/cloud/billing.py @@ -201,15 +201,17 @@ def run_topup( # noqa: PLR0911, PLR0912, PLR0915 emit(console, confirmed_receipt, as_json=as_json) return http.EXIT_OK + stdout = str(getattr(result, "stdout", "") or "").strip() + stderr = str(getattr(result, "stderr", "") or "").strip() if not as_json: console.print( "[yellow]The wallet exited without a confirmed receipt. The payment outcome is " "unknown; run `strix cloud billing credits` before retrying.[/]" ) + detail = _wallet_detail(stderr or stdout or "") + if detail: + console.print(f"[dim]Wallet output: {detail}[/]") return http.EXIT_PAYMENT - - stdout = str(getattr(result, "stdout", "") or "").strip() - stderr = str(getattr(result, "stderr", "") or "").strip() if result.returncode == 0: try: receipt = json.loads(stdout) @@ -415,22 +417,32 @@ def _run_link_wallet_flow( ) +def _embedded_json_documents(text: str) -> list[Any]: + """Extract JSON documents from wallet output that can contain other text.""" + documents: list[Any] = [] + decoder = json.JSONDecoder() + position = 0 + while position < len(text): + start_candidates = [ + index for index in (text.find("[", position), text.find("{", position)) if index != -1 + ] + if not start_candidates: + break + start = min(start_candidates) + try: + document, end = decoder.raw_decode(text, start) + except ValueError: + position = start + 1 + continue + documents.append(document) + position = end + return documents + + def _spend_request_records(stdout: str) -> list[dict[str, Any]]: """Parse spend-request records from JSON or JSON-lines wallet output.""" records: list[dict[str, Any]] = [] - text = (stdout or "").strip() - if not text: - return records - candidates: list[Any] = [] - try: - candidates.append(json.loads(text)) - except ValueError: - for line in text.splitlines(): - try: - candidates.append(json.loads(line)) - except ValueError: - continue - for candidate in candidates: + for candidate in _embedded_json_documents((stdout or "").strip()): items = candidate if isinstance(candidate, list) else [candidate] records.extend(cast("dict[str, Any]", item) for item in items if isinstance(item, dict)) return records diff --git a/tests/test_cloud_wallet.py b/tests/test_cloud_wallet.py index e5078964..33b48c86 100644 --- a/tests/test_cloud_wallet.py +++ b/tests/test_cloud_wallet.py @@ -74,6 +74,21 @@ def test_pending_spend_request_reads_the_created_record() -> None: assert billing._pending_spend_request("not json") is None +def test_pending_spend_request_tolerates_banner_text_around_pretty_json() -> None: + stdout = ( + "Update available for @stripe/link-cli: 0.13.1 -> 0.16.0\n" + "[\n {\n" + ' "id": "lsrq_9",\n' + ' "status": "pending_approval",\n' + ' "approval_url": "https://app.link.com/activity/approve/lsrq_9"\n' + " }\n]" + ) + assert billing._pending_spend_request(stdout) == ( + "lsrq_9", + "https://app.link.com/activity/approve/lsrq_9", + ) + + def test_final_spend_request_status_reads_the_last_poll_line() -> None: stdout = '{"status": "pending_approval"}\n{"status": "approved"}\n' assert billing._final_spend_request_status(stdout) == "approved"