Skip to content

Commit 3eeba5a

Browse files
committed
Add board CLI workflow test; pick .bin vs .uf2 and fix put -r, romfs, mpy-cross.
1 parent d3703b0 commit 3eeba5a

13 files changed

Lines changed: 1268 additions & 49 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ npm install
88
npm run compile # tsc
99
npm run lint # tsc --noEmit
1010
npm run test:python # unittest under cli/tests
11+
python3 tools/test_cli_workflows.py # attached boards; skip flash unless --install-latest-firmware
1112
npm run package # VSIX via @vscode/vsce
1213
```
1314

cli/src/mpftp/cli.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,13 @@ def _is_windows_python(python: str) -> bool:
317317

318318
# Env vars a Windows child spawned from WSL silently does not receive unless
319319
# named in WSLENV — MICROPYPATH is the reported case (mpftp#12): a Windows
320-
# micropython/mpremote falls back to its own default lib path with no error.
321-
_WSLENV_FORWARD_VARS = ("MICROPYPATH",)
320+
# Windows python.exe spawned from WSL only sees env vars listed in WSLENV.
321+
# /l = one path, /p = path list (PYTHONPATH). Without PYTHONPATH, a checkout
322+
# CLI would spawn the pip-installed sidecar and ignore local edits.
323+
_WSLENV_FORWARD = (
324+
("MICROPYPATH", "l"),
325+
("PYTHONPATH", "p"),
326+
)
322327

323328

324329
def _wslenv_forwarded_env(python: str) -> Optional[dict]:
@@ -328,12 +333,12 @@ def _wslenv_forwarded_env(python: str) -> Optional[dict]:
328333
wsl = os.environ.get("WSL_DISTRO_NAME") or os.environ.get("WSL_INTEROP")
329334
if not wsl or not _is_windows_python(python):
330335
return None
331-
to_forward = [v for v in _WSLENV_FORWARD_VARS if os.environ.get(v) is not None]
336+
to_forward = [(v, flag) for v, flag in _WSLENV_FORWARD if os.environ.get(v) is not None]
332337
if not to_forward:
333338
return None
334339
existing = [e.strip() for e in os.environ.get("WSLENV", "").split(":") if e.strip()]
335340
already = {e.split("/")[0] for e in existing}
336-
additions = [f"{v}/l" for v in to_forward if v not in already]
341+
additions = [f"{v}/{flag}" for v, flag in to_forward if v not in already]
337342
if not additions:
338343
return None
339344
env = dict(os.environ)
@@ -675,26 +680,27 @@ def cmd_tree(ns: argparse.Namespace) -> None:
675680

676681

677682
def cmd_put(ns: argparse.Namespace) -> None:
678-
data = Path(ns.local).read_bytes()
683+
local = Path(ns.local)
679684
client, mode = get_client()
680685
try:
681686
ensure_device(client, ns.device, ns.baud)
682687
dest = ns.remote
683688
mpy = bool(getattr(ns, "mpy", False))
684689
verify = bool(getattr(ns, "verify", True))
685-
if getattr(ns, "recursive", False) or Path(ns.local).is_dir():
690+
if getattr(ns, "recursive", False) or local.is_dir():
686691
out(
687692
client.call(
688693
"fs_cp",
689694
{
690-
"src": str(Path(ns.local).resolve()),
695+
"src": str(local.resolve()),
691696
"dest": ":" + dest if not dest.startswith(":") else dest,
692697
"verify": verify,
693698
"mpy": mpy,
694699
},
695700
)
696701
)
697702
return
703+
data = local.read_bytes()
698704
if mpy:
699705
# The board may compile to a different remote path (.py -> .mpy); the
700706
# source bytes on the CLI side aren't what ends up on the board, so
@@ -1398,6 +1404,8 @@ def cmd_firmware(ns: argparse.Namespace) -> None:
13981404
extra += ["--version", ns.version]
13991405
if getattr(ns, "preview", False):
14001406
extra.append("--preview")
1407+
if getattr(ns, "uf2", False):
1408+
extra.append("--uf2")
14011409
if getattr(ns, "force", False):
14021410
extra.append("--force")
14031411
res = _engine_stream("download", extra)
@@ -1840,6 +1848,11 @@ def build_parser() -> argparse.ArgumentParser:
18401848
)
18411849
fwdd.add_argument("--version", default="", help="Release version (e.g. 1.28.0)")
18421850
fwdd.add_argument("--preview", action="store_true", help="Latest preview build")
1851+
fwdd.add_argument(
1852+
"--uf2",
1853+
action="store_true",
1854+
help="Prefer .uf2 (default: .bin for esp32 serial, .uf2 for rp2/samd)",
1855+
)
18431856
fwdd.add_argument("--force", action="store_true", help="Refresh catalog cache")
18441857
fwdd.set_defaults(func=cmd_firmware)
18451858

cli/src/mpftp/firmware.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2575,6 +2575,7 @@ def progress(done: int, total: int) -> None:
25752575
version=getattr(ns, "version", None) or None,
25762576
preview=bool(getattr(ns, "preview", False)),
25772577
mp_variant=mp_variant,
2578+
uf2=bool(getattr(ns, "uf2", False)),
25782579
)
25792580
emit_log(f"[mpftp] downloading {chosen['url']}")
25802581
path = download_file(chosen["url"], progress=progress)
@@ -2723,6 +2724,11 @@ def add_mp(sp: argparse.ArgumentParser, required: bool = False) -> None:
27232724
)
27242725
dld.add_argument("--version", default="", help="release version (e.g. 1.28.0)")
27252726
dld.add_argument("--preview", action="store_true", help="latest preview build")
2727+
dld.add_argument(
2728+
"--uf2",
2729+
action="store_true",
2730+
help="prefer .uf2 (default: .bin for esp32, .uf2 for rp2/samd)",
2731+
)
27262732
dld.add_argument("--force", action="store_true", help="refresh catalog cache")
27272733
dld.set_defaults(func=do_download)
27282734

cli/src/mpftp/firmware_download.py

Lines changed: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -410,15 +410,55 @@ def enrich_downloads_from_page(
410410
return {"": json_default}
411411

412412

413+
def artifact_kind(url: str) -> str:
414+
"""``bin`` / ``uf2`` from a firmware URL, else empty."""
415+
u = (url or "").rsplit("?", 1)[0].lower()
416+
if u.endswith(".uf2"):
417+
return "uf2"
418+
if u.endswith(".bin"):
419+
return "bin"
420+
return ""
421+
422+
423+
def preferred_artifact_kind(variant: dict[str, Any], *, uf2: bool = False) -> str:
424+
"""Which image serial vs UF2 flashing wants.
425+
426+
Espressif boards are written with esptool (``.bin``). rp2/samd (and an
427+
explicit ``uf2=True``, e.g. ESP32-S3 + tinyuf2) want ``.uf2``.
428+
"""
429+
if uf2:
430+
return "uf2"
431+
port = (variant.get("port") or port_for_family(str(variant.get("family") or ""))).lower()
432+
if port in ("rp2", "samd"):
433+
return "uf2"
434+
return "bin"
435+
436+
437+
def _select_download(items: list[dict[str, str]], *, kind: str) -> Optional[dict[str, str]]:
438+
"""Prefer ``kind`` (bin/uf2); fall back to the first entry if none match."""
439+
if not items:
440+
return None
441+
for item in items:
442+
if artifact_kind(item.get("url") or "") == kind:
443+
return item
444+
return items[0]
445+
446+
413447
def pick_download(
414448
variant: dict[str, Any],
415449
*,
416450
version: Optional[str] = None,
417451
preview: bool = False,
418452
mp_variant: str = "",
419453
fetch: Optional[Fetcher] = None,
454+
uf2: bool = False,
420455
) -> dict[str, str]:
421-
"""Choose a download entry for board + MP variant (e.g. C6_WIFI)."""
456+
"""Choose a download entry for board + MP variant (e.g. C6_WIFI).
457+
458+
When both ``.bin`` and ``.uf2`` exist for the same version, prefer the
459+
format the flasher will use (``.bin`` for esp32/esptool, ``.uf2`` for
460+
rp2/samd). Pass ``uf2=True`` to force a UF2 (tinyuf2 on ESP32-S3, etc.).
461+
"""
422462
by_var = enrich_downloads_from_page(variant, fetch=fetch)
423463
mp_variant = mp_variant or ""
424464
if mp_variant not in by_var:
@@ -428,35 +468,46 @@ def pick_download(
428468
f"{variant.get('board')}; have: {known}"
429469
)
430470
downloads = by_var[mp_variant]
471+
kind = preferred_artifact_kind(variant, uf2=uf2)
472+
board = variant.get("board")
473+
extra = f" / {mp_variant}" if mp_variant else ""
474+
431475
if preview:
432-
for d in downloads:
433-
if d["channel"] == "preview":
434-
return d
435-
# Fall back to Thonny regex tweak filtered by variant name in URL.
476+
chosen = _select_download(
477+
[d for d in downloads if d["channel"] == "preview"], kind=kind
478+
)
479+
if chosen:
480+
return chosen
436481
patched = maybe_latest_preview(
437-
variant, fetch=fetch, mp_variant=mp_variant, by_variant=by_var
482+
variant,
483+
fetch=fetch,
484+
mp_variant=mp_variant,
485+
by_variant=by_var,
486+
kind=kind,
438487
)
439488
if patched:
440489
return patched
441-
raise RuntimeError(
442-
f"no preview build for {variant.get('board')}"
443-
+ (f" / {mp_variant}" if mp_variant else "")
444-
)
490+
raise RuntimeError(f"no preview build for {board}{extra}")
491+
445492
if version:
446493
ver = version.lstrip("v")
447-
for d in downloads:
448-
if d["version"].lstrip("v") == ver:
449-
return d
450-
raise RuntimeError(
451-
f"version {version} not found for {variant.get('board')}"
452-
+ (f" / {mp_variant}" if mp_variant else "")
494+
chosen = _select_download(
495+
[d for d in downloads if d["version"].lstrip("v") == ver], kind=kind
453496
)
454-
for d in downloads:
455-
if d["channel"] == "release":
456-
return d
497+
if chosen:
498+
return chosen
499+
raise RuntimeError(f"version {version} not found for {board}{extra}")
500+
501+
chosen = _select_download(
502+
[d for d in downloads if d["channel"] == "release"], kind=kind
503+
)
504+
if chosen:
505+
return chosen
457506
if downloads:
458-
return downloads[0]
459-
raise RuntimeError(f"no downloads for {variant.get('board')}")
507+
fallback = _select_download(downloads, kind=kind)
508+
if fallback:
509+
return fallback
510+
raise RuntimeError(f"no downloads for {board}")
460511

461512

462513
def maybe_latest_preview(
@@ -465,13 +516,15 @@ def maybe_latest_preview(
465516
fetch: Optional[Fetcher] = None,
466517
mp_variant: str = "",
467518
by_variant: Optional[dict[str, list[dict[str, str]]]] = None,
519+
kind: str = "",
468520
) -> Optional[dict[str, str]]:
469521
"""Pick latest preview for an MP variant from scraped page data."""
470522
if by_variant is None:
471523
by_variant = enrich_downloads_from_page(variant, fetch=fetch)
472-
for d in by_variant.get(mp_variant or "", []):
473-
if d["channel"] == "preview":
474-
return d
524+
previews = [d for d in by_variant.get(mp_variant or "", []) if d["channel"] == "preview"]
525+
chosen = _select_download(previews, kind=kind) if kind else (previews[0] if previews else None)
526+
if chosen:
527+
return chosen
475528
# Thonny regex fallback (base image only).
476529
regex_s = variant.get("latest_prerelease_regex")
477530
info_url = variant.get("info_url") or ""
@@ -501,6 +554,8 @@ def maybe_latest_preview(
501554
m = rx.search(name)
502555
if not m:
503556
continue
557+
if kind and artifact_kind(rel) and artifact_kind(rel) != kind:
558+
continue
504559
ver_m = re.search(r"(v?\d+\.\d+\.\d+-preview\.\d+\.[a-z0-9]+)", name, re.I)
505560
version = ver_m.group(1) if ver_m else m.group(0)
506561
return {
@@ -554,6 +609,7 @@ def download_board(
554609
version: Optional[str] = None,
555610
preview: bool = False,
556611
mp_variant: str = "",
612+
uf2: bool = False,
557613
data_prefix: str = DEFAULT_DATA_PREFIX,
558614
fetch: Optional[Fetcher] = None,
559615
catalog: Optional[list[dict[str, Any]]] = None,
@@ -568,6 +624,7 @@ def download_board(
568624
preview=preview,
569625
mp_variant=mp_variant or "",
570626
fetch=fetch,
627+
uf2=uf2,
571628
)
572629
path = download_file(chosen["url"])
573630
st = path.stat()

cli/src/mpftp/sidecar.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,37 @@ def _replace_remote_basename(remote_path: str, new_name: str) -> str:
5858
return new_name
5959

6060

61+
_ELF_MAGIC = b"\x7fELF"
62+
63+
64+
def _mpy_cross_runnable(path: Path) -> bool:
65+
"""True if this process can execute ``path`` (skip Linux ELF under Windows)."""
66+
if not path.is_file():
67+
return False
68+
if sys.platform == "win32":
69+
try:
70+
with open(path, "rb") as f:
71+
magic = f.read(4)
72+
except OSError:
73+
return False
74+
if magic == _ELF_MAGIC:
75+
return False
76+
return True
77+
78+
6179
def find_mpy_cross(micropython_hint: Optional[str] = None, workspace: Optional[str] = None) -> str:
62-
"""Resolve mpy-cross: firmware-workspace build -> PATH -> a clear error."""
80+
"""Resolve mpy-cross, then mpy-cross.exe: firmware-workspace build -> PATH."""
6381
from .firmware import find_micropython
6482

6583
mp = find_micropython(micropython_hint, workspace)
66-
if mp is not None:
67-
for name in ("mpy-cross", "mpy-cross.exe"):
84+
for name in ("mpy-cross", "mpy-cross.exe"):
85+
if mp is not None:
6886
candidate = mp / "mpy-cross" / "build" / name
69-
if candidate.is_file():
87+
if _mpy_cross_runnable(candidate):
7088
return str(candidate)
71-
found = shutil.which("mpy-cross")
72-
if found:
73-
return found
89+
found = shutil.which(name)
90+
if found and _mpy_cross_runnable(Path(found)):
91+
return found
7492
raise RuntimeError(
7593
"mpy-cross not found. Build it in your MicroPython tree (make -C mpy-cross), "
7694
"`pip install mpy-cross`, or point mpftp.workspacePath / mpftp.micropythonPath "
@@ -2689,10 +2707,15 @@ def romfs_build(
26892707
self._require_micropython("romfs")
26902708
from mpremote import commands as mp_cmd
26912709

2710+
# Host-only: mpremote still calls state.did_action() before make_romfs.
2711+
class _HostState:
2712+
def did_action(self):
2713+
pass
2714+
26922715
args = argparse.Namespace(path=path, output=output, mpy=mpy)
26932716
buf = io.StringIO()
26942717
with contextlib.redirect_stdout(buf):
2695-
mp_cmd._do_romfs_build(None, args)
2718+
mp_cmd._do_romfs_build(_HostState(), args)
26962719
out_file = output or (path + ".romfs")
26972720
size = Path(out_file).stat().st_size if Path(out_file).is_file() else 0
26982721
return {"output": buf.getvalue().strip(), "output_file": out_file, "size": size}

0 commit comments

Comments
 (0)