Parse wallet output that mixes banner text with JSON and show wallet detail on failure

This commit is contained in:
Alex Schapiro 2026-09-01 19:14:41 +00:00
parent f6a7a37fb8
commit 995ba47e05
2 changed files with 43 additions and 16 deletions

View file

@ -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

View file

@ -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"