fix(arduino-cli): library search/install failures become one actionable sentence
arduino-cli reports errors as plain text or, with --format json, as a JSON object on stderr. We forwarded that blob verbatim, so on a fresh install with no library index yet (and no way through the firewall/proxy to downloads.arduino.cc) the in-app agent told the user "the library server returns a JSON error" ten times in a row. humanize_cli_error() maps the known shapes — index download failed, library zip download failed, library not found — to a single sentence that says what to check, and keeps the raw text under raw_error. Applied to search_libraries (rc != 0 and non-JSON stdout) and install_library. Tests use the real arduino-cli 1.0.4 output captured offline.
This commit is contained in:
parent
f09489f498
commit
8abed79ad9
|
|
@ -23,6 +23,85 @@ def _looks_like_missing_header(stderr: str | None) -> bool:
|
|||
return bool(stderr and _MISSING_HEADER_RE.search(stderr))
|
||||
|
||||
|
||||
# ── arduino-cli error humanizer ──────────────────────────────────────────────
|
||||
# arduino-cli reports failures in two shapes: plain text on stderr, or (when
|
||||
# invoked with --format json) a JSON object {"error": "...", "warnings": [...]}
|
||||
# on stderr. We used to hand that raw blob straight to the caller, so a fresh
|
||||
# desktop install with no library index yet — and no way to reach
|
||||
# downloads.arduino.cc through the user's firewall / proxy — surfaced to the
|
||||
# in-app agent as "the library server returns a JSON error" (2026-08-17,
|
||||
# a Maker cancelled 16 minutes after subscribing). Turn the known shapes into
|
||||
# one actionable sentence and keep the raw text under a separate key.
|
||||
|
||||
_INDEX_DOWNLOAD_MARKERS = (
|
||||
"error downloading index",
|
||||
"library_index.tar.bz2",
|
||||
"library_index.json",
|
||||
"package_index.json",
|
||||
"error updating library index",
|
||||
"error initializing instance",
|
||||
)
|
||||
_NETWORK_MARKERS = (
|
||||
"downloads.arduino.cc",
|
||||
"dial tcp",
|
||||
"dial udp",
|
||||
"network is unreachable",
|
||||
"no such host",
|
||||
"connection refused",
|
||||
"tls handshake",
|
||||
"i/o timeout",
|
||||
"context deadline exceeded",
|
||||
"proxy",
|
||||
)
|
||||
|
||||
|
||||
def humanize_cli_error(raw: str | None, *, action: str = "run arduino-cli") -> str:
|
||||
"""One readable sentence for an arduino-cli failure.
|
||||
|
||||
`raw` is whatever arduino-cli wrote (stderr, or stdout+stderr). `action`
|
||||
names what we were doing ("search libraries", "install Adafruit GFX") so
|
||||
the message reads naturally in the UI and in the agent's tool result.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
# JSON-shaped error (--format json): {"error": "...", "warnings": [...]}
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
import json as _json
|
||||
obj = _json.loads(text)
|
||||
if isinstance(obj, dict):
|
||||
parts = [str(obj.get("error") or "")]
|
||||
warns = obj.get("warnings")
|
||||
if isinstance(warns, list):
|
||||
parts.extend(str(w) for w in warns)
|
||||
text = "\n".join(p for p in parts if p).strip()
|
||||
except ValueError:
|
||||
pass
|
||||
low = text.lower()
|
||||
|
||||
if any(m in low for m in _INDEX_DOWNLOAD_MARKERS) or (
|
||||
"index" in low and any(m in low for m in _NETWORK_MARKERS)
|
||||
):
|
||||
return (
|
||||
f"Could not {action}: the Arduino library index is not available yet "
|
||||
"and arduino-cli could not download it from downloads.arduino.cc. "
|
||||
"Check your internet connection, firewall or proxy and try again."
|
||||
)
|
||||
if "can't download library" in low or (
|
||||
"download" in low and any(m in low for m in _NETWORK_MARKERS)
|
||||
):
|
||||
return (
|
||||
f"Could not {action}: the download from downloads.arduino.cc failed. "
|
||||
"Check your internet connection, firewall or proxy and try again."
|
||||
)
|
||||
if "not found" in low or "no library found" in low:
|
||||
first = next((ln.strip() for ln in text.splitlines() if ln.strip()), text)
|
||||
return f"Could not {action}: {first[:300]}"
|
||||
if not text:
|
||||
return f"Could not {action}: arduino-cli returned no output."
|
||||
first = next((ln.strip() for ln in text.splitlines() if ln.strip()), text)
|
||||
return f"Could not {action}: {first[:300]}"
|
||||
|
||||
|
||||
class ArduinoCLIService:
|
||||
# Board manager URLs for cores that aren't built-in
|
||||
CORE_URLS: dict[str, str] = {
|
||||
|
|
@ -658,8 +737,12 @@ class ArduinoCLIService:
|
|||
|
||||
if result.returncode != 0:
|
||||
print(f"Error searching libraries: {stderr}")
|
||||
return {"success": False, "error": stderr}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": humanize_cli_error(stderr or stdout, action="search libraries"),
|
||||
"raw_error": stderr,
|
||||
}
|
||||
|
||||
import json
|
||||
try:
|
||||
results = json.loads(stdout)
|
||||
|
|
@ -686,7 +769,13 @@ class ArduinoCLIService:
|
|||
|
||||
return {"success": True, "libraries": libraries}
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "error": "Invalid output format from arduino-cli"}
|
||||
# rc was 0 but stdout is not JSON: arduino-cli printed a
|
||||
# progress/warning line ahead of (or instead of) the payload.
|
||||
return {
|
||||
"success": False,
|
||||
"error": humanize_cli_error(stderr or stdout, action="search libraries"),
|
||||
"raw_error": (stderr or stdout)[:2000],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception searching libraries: {e}")
|
||||
|
|
@ -763,7 +852,15 @@ class ArduinoCLIService:
|
|||
"requested_version": version,
|
||||
}
|
||||
print(f"Failed to install {library_name}: {result.stderr}")
|
||||
return {"success": False, "error": result.stderr, "stdout": result.stdout}
|
||||
return {
|
||||
"success": False,
|
||||
"error": humanize_cli_error(
|
||||
(result.stderr or "") + "\n" + (result.stdout or ""),
|
||||
action=f"install {library_name}",
|
||||
),
|
||||
"raw_error": result.stderr,
|
||||
"stdout": result.stdout,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception installing library: {e}")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
"""arduino-cli failures reach the UI / agent as one actionable sentence.
|
||||
|
||||
Regression for 2026-08-17: a fresh desktop install has no library index
|
||||
yet; when arduino-cli could not download it (firewall / proxy) `lib search`
|
||||
returned a JSON blob on stderr and we forwarded it verbatim, so the in-app
|
||||
agent told the user "the library server returns a JSON error" ten times in
|
||||
a row. The strings below are the real arduino-cli 1.0.4 output captured
|
||||
offline (`unshare -n`) against an empty data dir.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(_REPO / "backend"))
|
||||
|
||||
from app.services.arduino_cli import humanize_cli_error # noqa: E402
|
||||
|
||||
SEARCH_JSON_STDERR = (
|
||||
'{\n "error": "Error updating library index: Error downloading index '
|
||||
"'https://downloads.arduino.cc/libraries/library_index.tar.bz2': Get "
|
||||
'\\"https://downloads.arduino.cc/libraries/library_index.tar.bz2\\": dial tcp: '
|
||||
'lookup downloads.arduino.cc on 127.0.0.53:53: dial udp 127.0.0.53:53: connect: '
|
||||
'network is unreachable",\n "warnings": [\n "Error initializing instance: '
|
||||
"Error downloading index 'https://downloads.arduino.cc/libraries/library_index.tar.bz2'\"\n ]\n}"
|
||||
)
|
||||
|
||||
INSTALL_NO_INDEX_STDERR = (
|
||||
"Error initializing instance: Error downloading index "
|
||||
"'https://downloads.arduino.cc/libraries/library_index.tar.bz2': Get "
|
||||
'"https://downloads.arduino.cc/libraries/library_index.tar.bz2": dial tcp: lookup '
|
||||
"downloads.arduino.cc on 127.0.0.53:53: dial udp 127.0.0.53:53: connect: network is unreachable\n"
|
||||
"Error initializing instance: Loading index file: reading library_index.json: open "
|
||||
"/x/data/library_index.json: no such file or directory\n"
|
||||
)
|
||||
|
||||
INSTALL_NO_NET_WITH_INDEX = (
|
||||
"Downloading Adafruit GFX Library@1.12.6...\n"
|
||||
'Adafruit GFX Library@1.12.6 Get "https://downloads.arduino.cc/libraries/github.com/adafruit/'
|
||||
'Adafruit_GFX_Library-1.12.6.zip?query=install": dial tcp: lookup downloads.arduino.cc on '
|
||||
"127.0.0.53:53: dial udp 127.0.0.53:53: connect: network is unreachable\n"
|
||||
"Error installing Adafruit GFX Library: Can't download library: Get "
|
||||
'"https://downloads.arduino.cc/libraries/github.com/adafruit/Adafruit_GFX_Library-1.12.6.zip'
|
||||
'?query=install": dial tcp: lookup downloads.arduino.cc on 127.0.0.53:53: dial udp '
|
||||
"127.0.0.53:53: connect: network is unreachable\n"
|
||||
)
|
||||
|
||||
|
||||
def test_search_json_error_becomes_index_download_sentence():
|
||||
msg = humanize_cli_error(SEARCH_JSON_STDERR, action="search libraries")
|
||||
assert msg.startswith("Could not search libraries:")
|
||||
assert "library index" in msg
|
||||
assert "downloads.arduino.cc" in msg
|
||||
assert "firewall or proxy" in msg
|
||||
# No JSON leaks through to the user / agent.
|
||||
assert "{" not in msg and "warnings" not in msg
|
||||
|
||||
|
||||
def test_install_without_index_is_the_same_story():
|
||||
msg = humanize_cli_error(INSTALL_NO_INDEX_STDERR, action="install Adafruit GFX Library")
|
||||
assert msg.startswith("Could not install Adafruit GFX Library:")
|
||||
assert "library index" in msg
|
||||
assert "downloads.arduino.cc" in msg
|
||||
|
||||
|
||||
def test_install_with_index_but_no_network_names_the_download():
|
||||
msg = humanize_cli_error(INSTALL_NO_NET_WITH_INDEX, action="install Adafruit GFX Library")
|
||||
assert msg.startswith("Could not install Adafruit GFX Library:")
|
||||
assert "download from downloads.arduino.cc failed" in msg
|
||||
assert "firewall or proxy" in msg
|
||||
|
||||
|
||||
def test_unknown_library_passes_the_cli_line_through():
|
||||
msg = humanize_cli_error("Error installing NoSuchLib: Library 'NoSuchLib' not found", action="install NoSuchLib")
|
||||
assert msg == "Could not install NoSuchLib: Error installing NoSuchLib: Library 'NoSuchLib' not found"
|
||||
|
||||
|
||||
def test_empty_and_unknown_shapes_still_return_a_sentence():
|
||||
assert humanize_cli_error("", action="search libraries") == (
|
||||
"Could not search libraries: arduino-cli returned no output."
|
||||
)
|
||||
assert humanize_cli_error(None, action="search libraries").startswith("Could not search libraries:")
|
||||
long = "x" * 1000
|
||||
msg = humanize_cli_error(long, action="search libraries")
|
||||
assert msg.startswith("Could not search libraries: ") and len(msg) < 400
|
||||
|
||||
|
||||
def test_multiline_unknown_error_uses_first_line_only():
|
||||
msg = humanize_cli_error("\n\nfirst line here\nsecond line\n", action="search libraries")
|
||||
assert msg == "Could not search libraries: first line here"
|
||||
Loading…
Reference in New Issue