Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## Unreleased

- `firmware build --build-dir` works on esp32. `BUILD=` reached the mpy-cross
build the port starts for itself, which then wrote its objects into the same
folder and broke the firmware's link. mpftp now hands the port a ready
mpy-cross through `MICROPY_MPYCROSS`, so that sub-make never runs
(mpftp#46).

## v0.0.9 (2026-09-25)

- `mpftp --version` prints the version (mpftp#52).
Expand Down
15 changes: 13 additions & 2 deletions cli/src/mpftp/firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -1317,6 +1317,16 @@ def do_build(ns: argparse.Namespace) -> None:
build_override = str(Path(build_override).expanduser().resolve())
make_args.append(f"BUILD={build_override}")
emit_log(f"[mpftp] BUILD={build_override}")
# BUILD= on make's command line reaches every sub-make through
# MAKEFLAGS, including the mpy-cross build the port starts for itself
# (py/mkrules.cmake, py/mkrules.mk). That one would then build into
# this directory too, and its qstr and module fragments end up in the
# firmware (undefined mp_module_string, missing MP_QSTR_*; mpftp#46,
# micropython#19667). Naming the mpy-cross built below in
# MICROPY_MPYCROSS means the port never starts that sub-make.
if not env.get("MICROPY_MPYCROSS"):
env["MICROPY_MPYCROSS"] = str(mp / "mpy-cross" / "build" / "mpy-cross")
emit_log(f"[mpftp] MICROPY_MPYCROSS={env['MICROPY_MPYCROSS']}")

# Windows is a cross-compile from Linux/WSL: force the MinGW-w64 toolchain so
# make doesn't fall back to host gcc (which fails on <windows.h>). The MinGW
Expand Down Expand Up @@ -1371,9 +1381,10 @@ def do_build(ns: argparse.Namespace) -> None:

q_args = " ".join(_shq(a) for a in make_args)

# Host mpy-cross with cleared user modules (parity with build_mp.sh).
# Host mpy-cross with cleared user modules (parity with build_mp.sh), in
# its own build directory whatever BUILD the environment carries.
script_lines.append(
f'make -C "{mp}/mpy-cross" USER_C_MODULES= FROZEN_MANIFEST='
f'make -C "{mp}/mpy-cross" BUILD=build USER_C_MODULES= FROZEN_MANIFEST='
)

submodules = f'make {" ".join(j_arg)} submodules {q_args}'
Expand Down
32 changes: 31 additions & 1 deletion cli/tests/test_firmware_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import argparse
import io
import json
import os
import tempfile
import unittest
from contextlib import redirect_stdout
Expand Down Expand Up @@ -243,6 +244,10 @@ class TestBuildArgs(ModulesTestCase):
"""What do_build hands to make, with the shell and toolchains stubbed out."""

def build(self, **kw) -> tuple[list[str], list[dict]]:
lines, records, _envs = self.build_env(**kw)
return lines, records

def build_env(self, **kw) -> tuple[list[str], list[dict], list[dict]]:
ns = argparse.Namespace(
mp=str(self.mp), port="esp32", board="", variant="", board_dir="",
variant_dir="", build_dir="", module_roots=None, preset="", modules="",
Expand All @@ -251,9 +256,11 @@ def build(self, **kw) -> tuple[list[str], list[dict]]:
for k, v in kw.items():
setattr(ns, k, v)
scripts: list[list[str]] = []
envs: list[dict] = []

def fake_shell(lines, cwd, env):
scripts.append(list(lines))
envs.append(dict(env))
return 0, ""

out = io.StringIO()
Expand All @@ -267,7 +274,7 @@ def fake_shell(lines, cwd, env):
redirect_stdout(out):
firmware.do_build(ns)
records = [json.loads(ln) for ln in out.getvalue().splitlines() if ln.strip()]
return [ln for s in scripts for ln in s], records
return [ln for s in scripts for ln in s], records, envs

def test_p4_kitchen_sink_plus_earful(self) -> None:
lines, records = self.build(
Expand Down Expand Up @@ -296,6 +303,29 @@ def test_build_dir_override(self) -> None:
make_all = next(ln for ln in lines if " all " in ln)
self.assertIn(f"BUILD={bdir}", make_all)

def test_build_dir_keeps_mpy_cross_out_of_it(self) -> None:
# mpftp#46: BUILD= reaches the port's own mpy-cross sub-make through
# MAKEFLAGS. With MICROPY_MPYCROSS set, the port never starts one.
bdir = self.base / "scratch-build"
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("MICROPY_MPYCROSS", None)
os.environ["BUILD"] = "leaked-from-the-shell"
lines, _, envs = self.build_env(board="ESP32_GENERIC", build_dir=str(bdir))
mpy_cross = str(self.mp / "mpy-cross" / "build" / "mpy-cross")
self.assertTrue(envs)
for env in envs:
self.assertEqual(env.get("MICROPY_MPYCROSS"), mpy_cross)
prebuild = next(ln for ln in lines if "/mpy-cross\"" in ln)
self.assertIn("BUILD=build ", prebuild)

def test_default_build_dir_leaves_mpy_cross_to_the_port(self) -> None:
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("MICROPY_MPYCROSS", None)
_, _, envs = self.build_env(board="ESP32_GENERIC")
self.assertTrue(envs)
for env in envs:
self.assertNotIn("MICROPY_MPYCROSS", env)

def test_unknown_names_fail_before_make(self) -> None:
for kw, word in (({"modules": "nope"}, "Unknown module"),
({"preset": "nope"}, "Unknown preset"),
Expand Down
6 changes: 5 additions & 1 deletion docs/firmware-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ which is itself a preset), and then includes each ticked module's
`manifest.py`. Read it to see exactly what went into a build.

`--build-dir` builds somewhere other than the port's `build-<target>`
folder, which helps when several people share one checkout.
folder, which helps when several people share one checkout. It works on every
port, esp32 included: mpftp builds mpy-cross in its own folder first and names
it in `MICROPY_MPYCROSS`, so the port's mpy-cross sub-make, which would
otherwise inherit `BUILD=` and write its objects into your build folder, never
runs (mpftp#46).

The older sibling-folder aggregators (`micropython.cmake` and
`manifest-micropython.py` at the workspace root) are no longer read.
Loading